fix: could not input space in editor

This commit is contained in:
Lucas.Xu 2022-09-22 14:28:08 +08:00
parent 3cd9ea5366
commit c5af7db2cd
29 changed files with 799 additions and 450 deletions

View File

@ -0,0 +1,5 @@
{
"projects": {
"default": "appflowy-editor"
}
}

View File

@ -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": "*"
} ]
} ]
}
}

View File

@ -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<MyHomePage> {
child: AppFlowyEditor(
editorState: _editorState!,
editorStyle: _editorStyle,
editable: true,
shortcutEvents: [
underscoreToItalic,
],
@ -148,7 +152,7 @@ class _MyHomePageState extends State<MyHomePage> {
),
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<MyHomePage> {
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<void> _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) {

View File

@ -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<Node> 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<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return ImageNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> 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<ImageNodeWidget> createState() => _ImageNodeWidgetState();
}
class _ImageNodeWidgetState extends State<ImageNodeWidget>
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<Rect> 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,
),
)),
],
);
}
}

View File

@ -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<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
return LinkNodeWidget(
key: context.node.key,
node: context.node,
editorState: context.editorState,
);
}
@override
NodeValidator<Node> 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<LinkNodeWidget> createState() => _YouTubeLinkNodeWidgetState();
}
class _YouTubeLinkNodeWidgetState extends State<LinkNodeWidget>
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<Rect> 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),
],
);
}
}

View File

@ -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"))
}

View File

@ -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

View File

@ -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:

View File

@ -193,16 +193,24 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return parent!._path([index, ...previous]);
}
Node deepClone() {
final newNode = Node(
type: type, children: LinkedList<Node>(), attributes: {...attributes});
for (final node in children) {
final newNode = node.deepClone();
newNode.parent = this;
newNode.children.add(newNode);
Node copyWith({
String? type,
LinkedList<Node>? 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<Node>? 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<Node>? 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<Node>(),
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();

View File

@ -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) {

View File

@ -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;
}

View File

@ -36,7 +36,7 @@ class TransactionBuilder {
/// Inserts a sequence of nodes at the position of path.
insertNodes(Path path, List<Node> 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) {

View File

@ -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<T extends BuiltInTextWidget> on State<T> {
return const EdgeInsets.all(0);
}
}
mixin BuiltInTextWidgetMixin<T extends BuiltInTextWidget> on State<T>
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<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
),
)
],
)
],
);
}
}

View File

@ -45,7 +45,11 @@ class BulletedListTextNodeWidget extends BuiltInTextWidget {
// customize
class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@ -61,7 +65,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
}
@override
Widget build(BuildContext context) {
Widget buildWithSingle(BuildContext context) {
return Padding(
padding: padding,
child: Row(

View File

@ -46,7 +46,11 @@ class CheckboxNodeWidget extends BuiltInTextWidget {
}
class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
with SelectableMixin, DefaultSelectable, BuiltInStyleMixin {
with
SelectableMixin,
DefaultSelectable,
BuiltInStyleMixin,
BuiltInTextWidgetMixin {
@override
final iconKey = GlobalKey();
@ -62,15 +66,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
}
@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<CheckboxNodeWidget>
),
);
}
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<TextNode>(
context: context,
node: child,
editorState: widget.editorState,
)
: NodeWidgetContext<Node>(
context: context,
node: child,
editorState: widget.editorState,
),
),
)
.toList(),
)
],
)
],
);
}
}

View File

@ -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<AppFlowyEditor> createState() => _AppFlowyEditorState();
}
@ -106,11 +109,14 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
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,

View File

@ -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<AppFlowyInput>
EditorState get _editorState => widget.editorState;
// Disable space shortcut on the Web platform.
final Map<ShortcutActivator, Intent> _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,
);
}

View File

@ -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<Node> 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<Node> 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;
}

View File

@ -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.

View File

@ -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<TextNode>();
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;
};

View File

@ -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<ShortcutEvent> shortcutEvents;
final bool editable;
@override
State<AppFlowyKeyboard> createState() => _AppFlowyKeyboardState();
@ -62,7 +64,6 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
bool isFocus = true;
@override
// TODO: implement shortcutEvents
List<ShortcutEvent> get shortcutEvents => widget.shortcutEvents;
@override
@ -91,8 +92,12 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
@override
void enable() {
isFocus = true;
_focusNode.requestFocus();
if (widget.editable) {
isFocus = true;
_focusNode.requestFocus();
} else {
disable();
}
}
@override

View File

@ -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<AppFlowySelection> createState() => _AppFlowySelectionState();
@ -144,15 +146,21 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
@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<AppFlowySelection>
@override
void updateSelection(Selection? selection) {
if (!widget.editable) {
return;
}
selectionRects.clear();
clearSelection();
@ -323,6 +335,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
// 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<AppFlowySelection>
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;

View File

@ -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<ShortcutEvent> builtInShortcutEvents = [
command: 'page down',
handler: pageDownHandler,
),
ShortcutEvent(
key: 'Tab',
command: 'tab',
handler: tabHandler,
),
];

View File

@ -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;

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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;

View File

@ -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<void> _deleteFirstImage(WidgetTester tester, bool isBackward) async {

View File

@ -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,
);
});
});
}