From c5af7db2cd90af323b3e459b06013d4f627fe272 Mon Sep 17 00:00:00 2001 From: "Lucas.Xu" Date: Thu, 22 Sep 2022 14:28:08 +0800 Subject: [PATCH] fix: could not input space in editor --- .../appflowy_editor/example/.firebaserc | 5 + .../appflowy_editor/example/firebase.json | 23 +++ .../appflowy_editor/example/lib/main.dart | 71 +++++--- .../example/lib/plugin/image_node_widget.dart | 165 ------------------ .../lib/plugin/youtube_link_node_widget.dart | 100 ----------- .../Flutter/GeneratedPluginRegistrant.swift | 2 - .../example/macos/Podfile.lock | 6 - .../appflowy_editor/example/pubspec.yaml | 4 +- .../lib/src/document/node.dart | 67 +++---- .../lib/src/document/selection.dart | 6 +- .../lib/src/extensions/path_extensions.dart | 30 ++++ .../src/operation/transaction_builder.dart | 4 +- .../rich_text/built_in_text_widget.dart | 56 ++++++ .../render/rich_text/bulleted_list_text.dart | 8 +- .../src/render/rich_text/checkbox_text.dart | 52 +----- .../lib/src/service/editor_service.dart | 6 + .../lib/src/service/input_service.dart | 28 ++- .../backspace_handler.dart | 81 ++++++--- ...er_without_shift_in_text_node_handler.dart | 47 ++--- .../tab_handler.dart | 34 ++++ .../lib/src/service/keyboard_service.dart | 11 +- .../lib/src/service/selection_service.dart | 33 +++- .../built_in_shortcut_events.dart | 6 + .../shortcut_event/shortcut_event.dart | 6 +- .../test/document/node_test.dart | 153 ++++++++++++++++ .../test/extensions/path_extensions_test.dart | 38 ++++ .../test/infra/test_editor.dart | 1 + .../backspace_handler_test.dart | 55 +++++- .../tab_handler_test.dart | 151 ++++++++++++++++ 29 files changed, 799 insertions(+), 450 deletions(-) create mode 100644 frontend/app_flowy/packages/appflowy_editor/example/.firebaserc create mode 100644 frontend/app_flowy/packages/appflowy_editor/example/firebase.json delete mode 100644 frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart delete mode 100644 frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart create mode 100644 frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart diff --git a/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc b/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc new file mode 100644 index 0000000000..06fcc074c4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "appflowy-editor" + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/firebase.json b/frontend/app_flowy/packages/appflowy_editor/example/firebase.json new file mode 100644 index 0000000000..bba899ee87 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/example/firebase.json @@ -0,0 +1,23 @@ +{ + "hosting": { + "public": "build/web", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ], + "headers": [ { + "source": "**/*.@(png|jpg|jpeg|gif)", + "headers": [ { + "key": "Access-Control-Allow-Origin", + "value": "*" + } ] + } ] + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index ba4db4ae4c..fd5ccdeff3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -1,13 +1,16 @@ import 'dart:convert'; import 'dart:io'; -import 'package:example/plugin/underscore_to_italic.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + +import 'package:example/plugin/underscore_to_italic.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:google_fonts/google_fonts.dart'; - import 'package:path_provider/path_provider.dart'; +import 'package:universal_html/html.dart' as html; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -112,6 +115,7 @@ class _MyHomePageState extends State { child: AppFlowyEditor( editorState: _editorState!, editorStyle: _editorStyle, + editable: true, shortcutEvents: [ underscoreToItalic, ], @@ -148,7 +152,7 @@ class _MyHomePageState extends State { ), ActionButton( icon: const Icon(Icons.import_export), - onPressed: () => _importDocument(), + onPressed: () async => await _importDocument(), ), ActionButton( icon: const Icon(Icons.color_lens), @@ -167,28 +171,53 @@ class _MyHomePageState extends State { void _exportDocument(EditorState editorState) async { final document = editorState.document.toJson(); final json = jsonEncode(document); - final directory = await getTemporaryDirectory(); - final path = directory.path; - final file = File('$path/editor.json'); - await file.writeAsString(json); + if (kIsWeb) { + final blob = html.Blob([json], 'text/plain', 'native'); + html.AnchorElement( + href: html.Url.createObjectUrlFromBlob(blob).toString(), + ) + ..setAttribute('download', 'editor.json') + ..click(); + } else { + final directory = await getTemporaryDirectory(); + final path = directory.path; + final file = File('$path/editor.json'); + await file.writeAsString(json); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('The document is saved to the ${file.path}'), - ), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('The document is saved to the ${file.path}'), + ), + ); + } } } - void _importDocument() async { - final directory = await getTemporaryDirectory(); - final path = directory.path; - final file = File('$path/editor.json'); - setState(() { - _editorState = null; - _jsonString = file.readAsString(); - }); + Future _importDocument() async { + if (kIsWeb) { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + allowedExtensions: ['json'], + type: FileType.custom, + ); + final bytes = result?.files.first.bytes; + if (bytes != null) { + final jsonString = const Utf8Decoder().convert(bytes); + setState(() { + _editorState = null; + _jsonString = Future.value(jsonString); + }); + } + } else { + final directory = await getTemporaryDirectory(); + final path = '${directory.path}/editor.json'; + final file = File(path); + setState(() { + _editorState = null; + _jsonString = file.readAsString(); + }); + } } void _switchToPage(int pageIndex) { diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart deleted file mode 100644 index d76ce8d6a2..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/image_node_widget.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -/// 1. define your custom type in example.json -/// For example I need to define an image plugin, then I define type equals -/// "image", and add "image_src" into "attributes". -/// { -/// "type": "image", -/// "attributes", { "image_src": "https://s1.ax1x.com/2022/07/28/vCgz1x.png" } -/// } -/// 2. create a class extends [NodeWidgetBuilder] -/// 3. override the function `Widget build(NodeWidgetContext context)` -/// and return a widget to render. The returned widget should be -/// a StatefulWidget and mixin with [SelectableMixin]. -/// -/// 4. override the getter `nodeValidator` -/// to verify the data structure in [Node]. -/// 5. register the plugin with `type` to `AppFlowyEditor` in `main.dart`. -/// 6. Congratulations! - -class ImageNodeBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return ImageNodeWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.type == 'image'; - }); -} - -const double placeholderHeight = 132; - -class ImageNodeWidget extends StatefulWidget { - final Node node; - final EditorState editorState; - - const ImageNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - @override - State createState() => _ImageNodeWidgetState(); -} - -class _ImageNodeWidgetState extends State - with SelectableMixin { - bool isHovered = false; - Node get node => widget.node; - EditorState get editorState => widget.editorState; - String get src => widget.node.attributes['image_src'] as String; - - @override - Position end() { - return Position(path: node.path, offset: 0); - } - - @override - Position start() { - return Position(path: node.path, offset: 0); - } - - @override - List getRectsInSelection(Selection selection) { - return []; - } - - @override - Selection getSelectionInRange(Offset start, Offset end) { - return Selection.collapsed(Position(path: node.path, offset: 0)); - } - - @override - Offset localToGlobal(Offset offset) { - throw UnimplementedError(); - } - - @override - Position getPositionInOffset(Offset start) { - return Position(path: node.path, offset: 0); - } - - @override - Widget build(BuildContext context) { - return _build(context); - } - - Widget _loadingBuilder( - BuildContext context, Widget widget, ImageChunkEvent? evt) { - if (evt == null) { - return widget; - } - return Container( - alignment: Alignment.center, - height: placeholderHeight, - child: const Text("Loading..."), - ); - } - - Widget _errorBuilder( - BuildContext context, Object obj, StackTrace? stackTrace) { - return Container( - alignment: Alignment.center, - height: placeholderHeight, - child: const Text("Error..."), - ); - } - - Widget _frameBuilder( - BuildContext context, - Widget child, - int? frame, - bool wasSynchronouslyLoaded, - ) { - if (frame == null) { - return Container( - alignment: Alignment.center, - height: placeholderHeight, - child: const Text("Loading..."), - ); - } - - return child; - } - - Widget _build(BuildContext context) { - return Column( - children: [ - MouseRegion( - onEnter: (event) { - setState(() { - isHovered = true; - }); - }, - onExit: (event) { - setState(() { - isHovered = false; - }); - }, - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - border: Border.all( - color: isHovered ? Colors.blue : Colors.grey, - ), - borderRadius: const BorderRadius.all(Radius.circular(20))), - child: Image.network( - src, - width: MediaQuery.of(context).size.width, - frameBuilder: _frameBuilder, - loadingBuilder: _loadingBuilder, - errorBuilder: _errorBuilder, - ), - )), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart deleted file mode 100644 index 3923c60c45..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/plugin/youtube_link_node_widget.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; -import 'package:pod_player/pod_player.dart'; - -class YouTubeLinkNodeBuilder extends NodeWidgetBuilder { - @override - Widget build(NodeWidgetContext context) { - return LinkNodeWidget( - key: context.node.key, - node: context.node, - editorState: context.editorState, - ); - } - - @override - NodeValidator get nodeValidator => ((node) { - return node.type == 'youtube_link'; - }); -} - -class LinkNodeWidget extends StatefulWidget { - final Node node; - final EditorState editorState; - - const LinkNodeWidget({ - Key? key, - required this.node, - required this.editorState, - }) : super(key: key); - - @override - State createState() => _YouTubeLinkNodeWidgetState(); -} - -class _YouTubeLinkNodeWidgetState extends State - with SelectableMixin { - Node get node => widget.node; - EditorState get editorState => widget.editorState; - String get src => widget.node.attributes['youtube_link'] as String; - - @override - Position end() { - // TODO: implement end - throw UnimplementedError(); - } - - @override - Position start() { - // TODO: implement start - throw UnimplementedError(); - } - - @override - List getRectsInSelection(Selection selection) { - // TODO: implement getRectsInSelection - throw UnimplementedError(); - } - - @override - Selection getSelectionInRange(Offset start, Offset end) { - // TODO: implement getSelectionInRange - throw UnimplementedError(); - } - - @override - Offset localToGlobal(Offset offset) { - throw UnimplementedError(); - } - - @override - Position getPositionInOffset(Offset start) { - // TODO: implement getPositionInOffset - throw UnimplementedError(); - } - - @override - Widget build(BuildContext context) { - return _build(context); - } - - late final PodPlayerController controller; - - @override - void initState() { - controller = PodPlayerController( - playVideoFrom: PlayVideoFrom.network( - src, - ), - )..initialise(); - super.initState(); - } - - Widget _build(BuildContext context) { - return Column( - children: [ - PodVideoPlayer(controller: controller), - ], - ); - } -} diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift index 08b7c3b866..f0f250ebd6 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/app_flowy/packages/appflowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,11 +8,9 @@ import Foundation import path_provider_macos import rich_clipboard_macos import url_launcher_macos -import wakelock_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - WakelockMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockMacosPlugin")) } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock index 1fcb47735c..49a5879fa6 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock +++ b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock @@ -6,15 +6,12 @@ PODS: - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - - wakelock_macos (0.0.1): - - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - - wakelock_macos (from `Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: @@ -25,15 +22,12 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos - wakelock_macos: - :path: Flutter/ephemeral/.symlinks/plugins/wakelock_macos/macos SPEC CHECKSUMS: FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 - wakelock_macos: bc3f2a9bd8d2e6c89fee1e1822e7ddac3bd004a9 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c diff --git a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml index 7061a11226..3c3f51632e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/appflowy_editor/example/pubspec.yaml @@ -37,12 +37,12 @@ dependencies: path: ../ provider: ^6.0.3 url_launcher: ^6.1.5 - video_player: ^2.4.5 - pod_player: 0.0.8 path_provider: ^2.0.11 google_fonts: ^3.0.1 flutter_localizations: sdk: flutter + file_picker: ^5.0.1 + universal_html: ^2.0.8 dev_dependencies: flutter_test: diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart index bfc40c4d32..81e87399b1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/node.dart @@ -193,16 +193,24 @@ class Node extends ChangeNotifier with LinkedListEntry { return parent!._path([index, ...previous]); } - Node deepClone() { - final newNode = Node( - type: type, children: LinkedList(), attributes: {...attributes}); - - for (final node in children) { - final newNode = node.deepClone(); - newNode.parent = this; - newNode.children.add(newNode); + Node copyWith({ + String? type, + LinkedList? children, + Attributes? attributes, + }) { + final node = Node( + type: type ?? this.type, + attributes: attributes ?? {..._attributes}, + children: children ?? LinkedList(), + ); + if (children == null && this.children.isNotEmpty) { + for (final child in this.children) { + node.children.add( + child.copyWith()..parent = node, + ); + } } - return newNode; + return node; } } @@ -215,7 +223,10 @@ class TextNode extends Node { LinkedList? children, Attributes? attributes, }) : _delta = delta, - super(children: children ?? LinkedList(), attributes: attributes ?? {}); + super( + children: children ?? LinkedList(), + attributes: attributes ?? {}, + ); TextNode.empty({Attributes? attributes}) : _delta = Delta([TextInsert('')]), @@ -241,33 +252,27 @@ class TextNode extends Node { return map; } + @override TextNode copyWith({ String? type, LinkedList? children, Attributes? attributes, Delta? delta, - }) => - TextNode( - type: type ?? this.type, - children: children ?? this.children, - attributes: attributes ?? _attributes, - delta: delta ?? this.delta, - ); - - @override - TextNode deepClone() { - final newNode = TextNode( - type: type, - children: LinkedList(), - delta: delta.slice(0), - attributes: {...attributes}); - - for (final node in children) { - final newNode = node.deepClone(); - newNode.parent = this; - newNode.children.add(newNode); + }) { + final textNode = TextNode( + type: type ?? this.type, + children: children, + attributes: attributes ?? _attributes, + delta: delta ?? this.delta, + ); + if (children == null && this.children.isNotEmpty) { + for (final child in this.children) { + textNode.children.add( + child.copyWith()..parent = textNode, + ); + } } - return newNode; + return textNode; } String toRawString() => _delta.toRawString(); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index 986dd37468..ea451b46dd 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -40,11 +40,9 @@ class Selection { bool get isCollapsed => start == end; bool get isSingle => pathEquals(start.path, end.path); bool get isForward => - (start.path >= end.path && !pathEquals(start.path, end.path)) || - (isSingle && start.offset > end.offset); + (start.path > end.path) || (isSingle && start.offset > end.offset); bool get isBackward => - (start.path <= end.path && !pathEquals(start.path, end.path)) || - (isSingle && start.offset < end.offset); + (start.path < end.path) || (isSingle && start.offset < end.offset); Selection get normalize { if (isForward) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart index fb643443e4..b7955fe83a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/path_extensions.dart @@ -4,22 +4,52 @@ import 'dart:math'; extension PathExtensions on Path { bool operator >=(Path other) { + if (pathEquals(this, other)) { + return true; + } + return this > other; + } + + bool operator >(Path other) { + if (pathEquals(this, other)) { + return false; + } final length = min(this.length, other.length); for (var i = 0; i < length; i++) { if (this[i] < other[i]) { return false; + } else if (this[i] > other[i]) { + return true; } } + if (this.length < other.length) { + return false; + } return true; } bool operator <=(Path other) { + if (pathEquals(this, other)) { + return true; + } + return this < other; + } + + bool operator <(Path other) { + if (pathEquals(this, other)) { + return false; + } final length = min(this.length, other.length); for (var i = 0; i < length; i++) { if (this[i] > other[i]) { return false; + } else if (this[i] < other[i]) { + return true; } } + if (this.length > other.length) { + return false; + } return true; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index c990a3921f..7dfca6bcf7 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart @@ -36,7 +36,7 @@ class TransactionBuilder { /// Inserts a sequence of nodes at the position of path. insertNodes(Path path, List nodes) { beforeSelection = state.cursorSelection; - add(InsertOperation(path, nodes.map((node) => node.deepClone()).toList())); + add(InsertOperation(path, nodes.map((node) => node.copyWith()).toList())); } /// Updates the attributes of nodes. @@ -75,7 +75,7 @@ class TransactionBuilder { nodes.add(node); } - add(DeleteOperation(path, nodes.map((node) => node.deepClone()).toList())); + add(DeleteOperation(path, nodes.map((node) => node.copyWith()).toList())); } textEdit(TextNode node, Delta Function() f) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart index 954d5bd516..f2c8693f87 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/built_in_text_widget.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/rich_text/default_selectable.dart'; import 'package:flutter/material.dart'; abstract class BuiltInTextWidget extends StatefulWidget { @@ -59,3 +60,58 @@ mixin BuiltInStyleMixin on State { return const EdgeInsets.all(0); } } + +mixin BuiltInTextWidgetMixin on State + implements DefaultSelectable { + @override + Widget build(BuildContext context) { + if (widget.textNode.children.isEmpty) { + return buildWithSingle(context); + } else { + return buildWithChildren(context); + } + } + + Widget buildWithSingle(BuildContext context); + + Widget buildWithChildren(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildWithSingle(context), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // TODO: customize + const SizedBox( + width: 20, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widget.textNode.children + .map( + (child) => widget.editorState.service.renderPluginService + .buildPluginWidget( + child is TextNode + ? NodeWidgetContext( + context: context, + node: child, + editorState: widget.editorState, + ) + : NodeWidgetContext( + context: context, + node: child, + editorState: widget.editorState, + ), + ), + ) + .toList(), + ), + ) + ], + ) + ], + ); + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart index ac0534a5f4..5558b8934d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart @@ -45,7 +45,11 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget { // customize class _BulletedListTextNodeWidgetState extends State - with SelectableMixin, DefaultSelectable, BuiltInStyleMixin { + with + SelectableMixin, + DefaultSelectable, + BuiltInStyleMixin, + BuiltInTextWidgetMixin { @override final iconKey = GlobalKey(); @@ -61,7 +65,7 @@ class _BulletedListTextNodeWidgetState extends State } @override - Widget build(BuildContext context) { + Widget buildWithSingle(BuildContext context) { return Padding( padding: padding, child: Row( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 0c6295f4ce..2ca7531d2b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -46,7 +46,11 @@ class CheckboxNodeWidget extends BuiltInTextWidget { } class _CheckboxNodeWidgetState extends State - with SelectableMixin, DefaultSelectable, BuiltInStyleMixin { + with + SelectableMixin, + DefaultSelectable, + BuiltInStyleMixin, + BuiltInTextWidgetMixin { @override final iconKey = GlobalKey(); @@ -62,15 +66,7 @@ class _CheckboxNodeWidgetState extends State } @override - Widget build(BuildContext context) { - if (widget.textNode.children.isEmpty) { - return _buildWithSingle(context); - } else { - return _buildWithChildren(context); - } - } - - Widget _buildWithSingle(BuildContext context) { + Widget buildWithSingle(BuildContext context) { final check = widget.textNode.attributes.check; return Padding( padding: padding, @@ -106,40 +102,4 @@ class _CheckboxNodeWidgetState extends State ), ); } - - Widget _buildWithChildren(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildWithSingle(context), - Row( - children: [ - const SizedBox( - width: 20, - ), - Column( - children: widget.textNode.children - .map( - (child) => widget.editorState.service.renderPluginService - .buildPluginWidget( - child is TextNode - ? NodeWidgetContext( - context: context, - node: child, - editorState: widget.editorState, - ) - : NodeWidgetContext( - context: context, - node: child, - editorState: widget.editorState, - ), - ), - ) - .toList(), - ) - ], - ) - ], - ); - } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 654d8e4b26..2655717c1d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -38,6 +38,7 @@ class AppFlowyEditor extends StatefulWidget { this.customBuilders = const {}, this.shortcutEvents = const [], this.selectionMenuItems = const [], + this.editable = true, required this.editorStyle, }) : super(key: key); @@ -53,6 +54,8 @@ class AppFlowyEditor extends StatefulWidget { final EditorStyle editorStyle; + final bool editable; + @override State createState() => _AppFlowyEditorState(); } @@ -106,11 +109,14 @@ class _AppFlowyEditorState extends State { cursorColor: widget.editorStyle.cursorColor, selectionColor: widget.editorStyle.selectionColor, editorState: editorState, + editable: widget.editable, child: AppFlowyInput( key: editorState.service.inputServiceKey, editorState: editorState, + editable: widget.editable, child: AppFlowyKeyboard( key: editorState.service.keyboardServiceKey, + editable: widget.editable, shortcutEvents: [ ...builtInShortcutEvents, ...widget.shortcutEvents, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index a92fae1b95..7f1a4718f5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/src/infra/log.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -43,11 +44,13 @@ abstract class AppFlowyInputService { class AppFlowyInput extends StatefulWidget { const AppFlowyInput({ Key? key, + this.editable = true, required this.editorState, required this.child, }) : super(key: key); final EditorState editorState; + final bool editable; final Widget child; @override @@ -61,26 +64,39 @@ class _AppFlowyInputState extends State EditorState get _editorState => widget.editorState; + // Disable space shortcut on the Web platform. + final Map _shortcuts = kIsWeb + ? { + LogicalKeySet(LogicalKeyboardKey.space): + DoNothingAndStopPropagationIntent(), + } + : {}; + @override void initState() { super.initState(); - _editorState.service.selectionService.currentSelection - .addListener(_onSelectionChange); + if (widget.editable) { + _editorState.service.selectionService.currentSelection + .addListener(_onSelectionChange); + } } @override void dispose() { - close(); - _editorState.service.selectionService.currentSelection - .removeListener(_onSelectionChange); + if (widget.editable) { + close(); + _editorState.service.selectionService.currentSelection + .removeListener(_onSelectionChange); + } super.dispose(); } @override Widget build(BuildContext context) { - return Container( + return Shortcuts( + shortcuts: _shortcuts, child: widget.child, ); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart index 9d65088914..3df482012f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart @@ -1,8 +1,8 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; // Handle delete text. ShortcutEventHandler deleteTextHandler = (editorState, event) { @@ -121,32 +121,40 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { } KeyEventResult _backDeleteToPreviousTextNode( - EditorState editorState, - TextNode textNode, - TransactionBuilder transactionBuilder, - List nonTextNodes, - Selection selection) { - var previous = textNode.previous; - bool prevIsNumberList = false; - while (previous != null) { - if (previous is TextNode) { - if (previous.subtype == BuiltInAttributeKey.numberList) { - prevIsNumberList = true; - } + EditorState editorState, + TextNode textNode, + TransactionBuilder transactionBuilder, + List nonTextNodes, + Selection selection, +) { + // Not reach to the root. + if (textNode.parent?.parent != null) { + transactionBuilder + ..deleteNode(textNode) + ..insertNode(textNode.parent!.path.next, textNode) + ..afterSelection = Selection.collapsed( + Position(path: textNode.parent!.path.next, offset: 0), + ) + ..commit(); + return KeyEventResult.handled; + } - transactionBuilder - ..mergeText(previous, textNode) - ..deleteNode(textNode) - ..afterSelection = Selection.collapsed( - Position( - path: previous.path, - offset: previous.toRawString().length, - ), - ); - break; - } else { - previous = previous.previous; + bool prevIsNumberList = false; + final previousTextNode = _closestTextNode(textNode.previous); + if (previousTextNode != null && previousTextNode is TextNode) { + if (previousTextNode.subtype == BuiltInAttributeKey.numberList) { + prevIsNumberList = true; } + + transactionBuilder + ..mergeText(previousTextNode, textNode) + ..deleteNode(textNode) + ..afterSelection = Selection.collapsed( + Position( + path: previousTextNode.path, + offset: previousTextNode.toRawString().length, + ), + ); } if (transactionBuilder.operations.isNotEmpty) { @@ -157,8 +165,8 @@ KeyEventResult _backDeleteToPreviousTextNode( } if (prevIsNumberList) { - makeFollowingNodesIncremental( - editorState, previous!.path, transactionBuilder.afterSelection!); + makeFollowingNodesIncremental(editorState, previousTextNode!.path, + transactionBuilder.afterSelection!); } return KeyEventResult.handled; @@ -261,3 +269,22 @@ void _deleteTextNodes(TransactionBuilder transactionBuilder, secondOffset: selection.end.offset, ); } + +// TODO: Just a simple solution for textNode, need to be optimized. +Node? _closestTextNode(Node? node) { + if (node is TextNode) { + var children = node.children; + if (children.isEmpty) { + return node; + } + var last = children.last; + while (last.children.isNotEmpty) { + last = children.last; + } + return last; + } + if (node?.previous != null) { + return _closestTextNode(node!.previous!); + } + return null; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index a82e751083..a981470f6e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart @@ -1,9 +1,9 @@ +import 'dart:collection'; + import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:appflowy_editor/src/extensions/path_extensions.dart'; -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; import './number_list_helper.dart'; /// Handle some cases where enter is pressed and shift is not pressed. @@ -16,10 +16,6 @@ import './number_list_helper.dart'; /// 2.2 or insert a empty text node before. ShortcutEventHandler enterWithoutShiftInTextNodesHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.enter || event.isShiftPressed) { - return KeyEventResult.ignored; - } - var selection = editorState.service.selectionService.currentSelection.value; var nodes = editorState.service.selectionService.currentSelectedNodes; if (selection == null) { @@ -124,7 +120,10 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler = TransactionBuilder(editorState) ..insertNode( textNode.path, - TextNode.empty(), + textNode.copyWith( + children: LinkedList(), + delta: Delta(), + ), ) ..afterSelection = afterSelection ..commit(); @@ -142,21 +141,25 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler = Position(path: nextPath, offset: 0), ); - TransactionBuilder(editorState) - ..insertNode( - textNode.path.next, - textNode.copyWith( - attributes: attributes, - delta: textNode.delta.slice(selection.end.offset), - ), - ) - ..deleteText( - textNode, - selection.start.offset, - textNode.toRawString().length - selection.start.offset, - ) - ..afterSelection = afterSelection - ..commit(); + final transactionBuilder = TransactionBuilder(editorState); + transactionBuilder.insertNode( + textNode.path.next, + textNode.copyWith( + attributes: attributes, + delta: textNode.delta.slice(selection.end.offset), + ), + ); + transactionBuilder.deleteText( + textNode, + selection.start.offset, + textNode.toRawString().length - selection.start.offset, + ); + if (textNode.children.isNotEmpty) { + final children = textNode.children.toList(growable: false); + transactionBuilder.deleteNodes(children); + } + transactionBuilder.afterSelection = afterSelection; + transactionBuilder.commit(); // If the new type of a text node is number list, // the numbers of the following nodes should be incremental. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart new file mode 100644 index 0000000000..0eb36fff17 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/tab_handler.dart @@ -0,0 +1,34 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +ShortcutEventHandler tabHandler = (editorState, event) { + // Only Supports BulletedList For Now. + + final selection = editorState.service.selectionService.currentSelection.value; + final textNodes = editorState.service.selectionService.currentSelectedNodes + .whereType(); + if (textNodes.length != 1 || selection == null || !selection.isSingle) { + return KeyEventResult.ignored; + } + + final textNode = textNodes.first; + final previous = textNode.previous; + if (textNode.subtype != BuiltInAttributeKey.bulletedList || + previous == null || + previous.subtype != BuiltInAttributeKey.bulletedList) { + return KeyEventResult.handled; + } + + final path = previous.path + [previous.children.length]; + final afterSelection = Selection( + start: selection.start.copyWith(path: path), + end: selection.end.copyWith(path: path), + ); + TransactionBuilder(editorState) + ..deleteNode(textNode) + ..insertNode(path, textNode) + ..setAfterSelection(afterSelection) + ..commit(); + + return KeyEventResult.handled; +}; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart index 790e9dd94f..5259872b95 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -42,6 +42,7 @@ abstract class AppFlowyKeyboardService { class AppFlowyKeyboard extends StatefulWidget { const AppFlowyKeyboard({ Key? key, + this.editable = true, required this.shortcutEvents, required this.editorState, required this.child, @@ -50,6 +51,7 @@ class AppFlowyKeyboard extends StatefulWidget { final EditorState editorState; final Widget child; final List shortcutEvents; + final bool editable; @override State createState() => _AppFlowyKeyboardState(); @@ -62,7 +64,6 @@ class _AppFlowyKeyboardState extends State bool isFocus = true; @override - // TODO: implement shortcutEvents List get shortcutEvents => widget.shortcutEvents; @override @@ -91,8 +92,12 @@ class _AppFlowyKeyboardState extends State @override void enable() { - isFocus = true; - _focusNode.requestFocus(); + if (widget.editable) { + isFocus = true; + _focusNode.requestFocus(); + } else { + disable(); + } } @override diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart index d755e8c9f9..d9b5422aa1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -84,6 +84,7 @@ class AppFlowySelection extends StatefulWidget { Key? key, this.cursorColor = const Color(0xFF00BCF0), this.selectionColor = const Color.fromARGB(53, 111, 201, 231), + this.editable = true, required this.editorState, required this.child, }) : super(key: key); @@ -92,6 +93,7 @@ class AppFlowySelection extends StatefulWidget { final Widget child; final Color cursorColor; final Color selectionColor; + final bool editable; @override State createState() => _AppFlowySelectionState(); @@ -144,15 +146,21 @@ class _AppFlowySelectionState extends State @override Widget build(BuildContext context) { - return SelectionGestureDetector( - onPanStart: _onPanStart, - onPanUpdate: _onPanUpdate, - onPanEnd: _onPanEnd, - onTapDown: _onTapDown, - onDoubleTapDown: _onDoubleTapDown, - onTripleTapDown: _onTripleTapDown, - child: widget.child, - ); + if (!widget.editable) { + return Container( + child: widget.child, + ); + } else { + return SelectionGestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onTapDown: _onTapDown, + onDoubleTapDown: _onDoubleTapDown, + onTripleTapDown: _onTripleTapDown, + child: widget.child, + ); + } } @override @@ -184,6 +192,10 @@ class _AppFlowySelectionState extends State @override void updateSelection(Selection? selection) { + if (!widget.editable) { + return; + } + selectionRects.clear(); clearSelection(); @@ -323,6 +335,7 @@ class _AppFlowySelectionState extends State // compute the selection in range. if (first != null && last != null) { + Log.selection.debug('first = $first, last = $last'); final start = first.getSelectionInRange(panStartOffset, panEndOffset).start; final end = last.getSelectionInRange(panStartOffset, panEndOffset).end; @@ -353,6 +366,8 @@ class _AppFlowySelectionState extends State final normalizedSelection = selection.normalize; assert(normalizedSelection.isBackward); + Log.selection.debug('update selection areas, $normalizedSelection'); + for (var i = 0; i < backwardNodes.length; i++) { final node = backwardNodes[i]; final selectable = node.selectable; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart index eafee79a6d..38eb9ee7c5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/built_in_shortcut_events.dart @@ -9,6 +9,7 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_und import 'package:appflowy_editor/src/service/internal_key_event_handlers/select_all_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/format_style_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/tab_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/whitespace_handler.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event.dart'; @@ -243,4 +244,9 @@ List builtInShortcutEvents = [ command: 'page down', handler: pageDownHandler, ), + ShortcutEvent( + key: 'Tab', + command: 'tab', + handler: tabHandler, + ), ]; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart index ae64b1635c..fb1a245b00 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/shortcut_event/shortcut_event.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:appflowy_editor/src/service/shortcut_event/keybinding.dart'; import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart'; +import 'package:flutter/foundation.dart'; /// Defines the implementation of shortcut event. class ShortcutEvent { @@ -56,7 +57,10 @@ class ShortcutEvent { String? linuxCommand, }) { var matched = false; - if (Platform.isWindows && + if (kIsWeb && command != null && command.isNotEmpty) { + this.command = command; + matched = true; + } else if (Platform.isWindows && windowsCommand != null && windowsCommand.isNotEmpty) { this.command = windowsCommand; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart new file mode 100644 index 0000000000..3ff00eaa7a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/document/node_test.dart @@ -0,0 +1,153 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('node.dart', () { + test('test node copyWith', () { + final node = Node( + type: 'example', + children: LinkedList(), + attributes: { + 'example': 'example', + }, + ); + expect(node.toJson(), { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + }); + expect( + node.copyWith().toJson(), + node.toJson(), + ); + + final nodeWithChildren = Node( + type: 'example', + children: LinkedList()..add(node), + attributes: { + 'example': 'example', + }, + ); + expect(nodeWithChildren.toJson(), { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + 'children': [ + { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + }, + ], + }); + expect( + nodeWithChildren.copyWith().toJson(), + nodeWithChildren.toJson(), + ); + }); + + test('test textNode copyWith', () { + final textNode = TextNode( + type: 'example', + children: LinkedList(), + attributes: { + 'example': 'example', + }, + delta: Delta()..insert('AppFlowy'), + ); + expect(textNode.toJson(), { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + 'delta': [ + {'insert': 'AppFlowy'}, + ], + }); + expect( + textNode.copyWith().toJson(), + textNode.toJson(), + ); + + final textNodeWithChildren = TextNode( + type: 'example', + children: LinkedList()..add(textNode), + attributes: { + 'example': 'example', + }, + delta: Delta()..insert('AppFlowy'), + ); + expect(textNodeWithChildren.toJson(), { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + 'delta': [ + {'insert': 'AppFlowy'}, + ], + 'children': [ + { + 'type': 'example', + 'attributes': { + 'example': 'example', + }, + 'delta': [ + {'insert': 'AppFlowy'}, + ], + }, + ], + }); + expect( + textNodeWithChildren.copyWith().toJson(), + textNodeWithChildren.toJson(), + ); + }); + + test('test node path', () { + Node previous = Node( + type: 'example', + attributes: {}, + children: LinkedList(), + ); + const len = 10; + for (var i = 0; i < len; i++) { + final node = Node( + type: 'example_$i', + attributes: {}, + children: LinkedList(), + ); + previous.children.add(node..parent = previous); + previous = node; + } + expect(previous.path, List.filled(len, 0)); + }); + + test('test copy with', () { + final child = Node( + type: 'child', + attributes: {}, + children: LinkedList(), + ); + final base = Node( + type: 'base', + attributes: {}, + children: LinkedList()..add(child), + ); + final node = base.copyWith( + type: 'node', + ); + expect(identical(node.attributes, base.attributes), false); + expect(identical(node.children, base.children), false); + expect(identical(node.children.first, base.children.first), false); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart new file mode 100644 index 0000000000..c39c0d0e56 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/extensions/path_extensions_test.dart @@ -0,0 +1,38 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:appflowy_editor/src/extensions/path_extensions.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('path_extensions.dart', () { + test('test path equality', () { + var p1 = [0, 0]; + var p2 = [0]; + + expect(p1 > p2, true); + expect(p1 >= p2, true); + expect(p1 < p2, false); + expect(p1 <= p2, false); + + p1 = [1, 1, 2]; + p2 = [1, 1, 3]; + + expect(p2 > p1, true); + expect(p2 >= p1, true); + expect(p2 < p1, false); + expect(p2 <= p1, false); + + p1 = [2, 0, 1]; + p2 = [2, 0, 1]; + + expect(p2 > p1, false); + expect(p1 > p2, false); + expect(p2 >= p1, true); + expect(p2 <= p1, true); + expect(pathEquals(p1, p2), true); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart index de69f95803..b4282a013e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart @@ -19,6 +19,7 @@ class EditorWidgetTester { EditorState get editorState => _editorState; Node get root => _editorState.document.root; + StateTree get document => _editorState.document; int get documentLength => _editorState.document.root.children.length; Selection? get documentSelection => _editorState.service.selectionService.currentSelection.value; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart index 540dca6e31..f40e8a8fa5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart @@ -4,7 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:network_image_mock/network_image_mock.dart'; import '../../infra/test_editor.dart'; -import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart'; void main() async { setUpAll(() { @@ -267,6 +266,60 @@ void main() async { BuiltInAttributeKey.h1, ); }); + + testWidgets('Delete the nested bulleted list', (tester) async { + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + const text = 'Welcome to Appflowy 😁'; + final node = TextNode( + type: 'text', + delta: Delta()..insert(text), + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, + }, + ); + node.insert( + node.copyWith() + ..insert( + node.copyWith(), + ), + ); + + final editor = tester.editor..insert(node); + await editor.startTesting(); + + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + await editor.updateSelection( + Selection.single(path: [0, 0, 0], startOffset: 0), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.nodeAtPath([0, 0, 0])?.subtype, null); + await editor.updateSelection( + Selection.single(path: [0, 0, 0], startOffset: 0), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.nodeAtPath([0, 1]) != null, true); + await editor.updateSelection( + Selection.single(path: [0, 1], startOffset: 0), + ); + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.nodeAtPath([1]) != null, true); + await editor.updateSelection( + Selection.single(path: [1], startOffset: 0), + ); + + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁Welcome to Appflowy 😁 + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect( + editor.documentSelection, + Selection.single(path: [0, 0], startOffset: text.length), + ); + expect((editor.nodeAtPath([0, 0]) as TextNode).toRawString(), text * 2); + }); } Future _deleteFirstImage(WidgetTester tester, bool isBackward) async { diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart new file mode 100644 index 0000000000..1374869deb --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/tab_handler_test.dart @@ -0,0 +1,151 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../infra/test_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('tab_handler.dart', () { + testWidgets('press tab in plain text', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + final document = editor.document; + + var selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + // nothing happens + expect(editor.documentSelection, selection); + expect(editor.document.toJson(), document.toJson()); + + selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + // nothing happens + expect(editor.documentSelection, selection); + expect(editor.document.toJson(), document.toJson()); + }); + + testWidgets('press tab in bulleted list', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList + }, + ) + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList + }, + ) + ..insertTextNode( + text, + attributes: { + BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList + }, + ); + await editor.startTesting(); + var document = editor.document; + + var selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + // nothing happens + expect(editor.documentSelection, selection); + expect(editor.document.toJson(), document.toJson()); + + // Before + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // After + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + + selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0], startOffset: 0), + ); + expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList); + expect(editor.nodeAtPath([1])!.subtype, BuiltInAttributeKey.bulletedList); + expect(editor.nodeAtPath([2]), null); + expect( + editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList); + + selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 1], startOffset: 0), + ); + expect(editor.nodeAtPath([0])!.subtype, BuiltInAttributeKey.bulletedList); + expect(editor.nodeAtPath([1]), null); + expect(editor.nodeAtPath([2]), null); + expect( + editor.nodeAtPath([0, 0])!.subtype, BuiltInAttributeKey.bulletedList); + expect( + editor.nodeAtPath([0, 1])!.subtype, BuiltInAttributeKey.bulletedList); + + // Before + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // After + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + // * Welcome to Appflowy 😁 + document = editor.document; + selection = Selection.single(path: [0, 0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0], startOffset: 0), + ); + expect(editor.document.toJson(), document.toJson()); + + selection = Selection.single(path: [0, 1], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressLogicKey(LogicalKeyboardKey.tab); + + expect( + editor.documentSelection, + Selection.single(path: [0, 0, 0], startOffset: 0), + ); + expect( + editor.nodeAtPath([0])!.subtype, + BuiltInAttributeKey.bulletedList, + ); + expect( + editor.nodeAtPath([0, 0])!.subtype, + BuiltInAttributeKey.bulletedList, + ); + expect(editor.nodeAtPath([0, 1]), null); + expect( + editor.nodeAtPath([0, 0, 0])!.subtype, + BuiltInAttributeKey.bulletedList, + ); + }); + }); +}