feat: implement node extension, allSatisfyInSelection

This commit is contained in:
Lucas.Xu 2023-04-28 19:48:34 +08:00
commit 2bf07055ed
19 changed files with 563 additions and 203 deletions

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.201 6.4H3.001V12H2.081V7.384L0.953 7.704L0.729 6.92L2.201 6.4ZM3.91156 12V11.1L6.35156 8.61C6.9449 8.01667 7.24156 7.50333 7.24156 7.07C7.24156 6.73 7.13823 6.46667 6.93156 6.28C6.73156 6.08667 6.4749 5.99 6.16156 5.99C5.5749 5.99 5.14156 6.28 4.86156 6.86L3.89156 6.29C4.11156 5.82333 4.42156 5.47 4.82156 5.23C5.22156 4.99 5.6649 4.87 6.15156 4.87C6.7649 4.87 7.29156 5.06333 7.73156 5.45C8.17156 5.83667 8.39156 6.36333 8.39156 7.03C8.39156 7.74333 7.9949 8.50333 7.20156 9.31L5.62156 10.89H8.52156V12H3.91156ZM12.9025 7.032C13.5105 7.176 14.0025 7.46 14.3785 7.884C14.7625 8.3 14.9545 8.824 14.9545 9.456C14.9545 10.296 14.6705 10.956 14.1025 11.436C13.5345 11.916 12.8385 12.156 12.0145 12.156C11.3745 12.156 10.7985 12.008 10.2865 11.712C9.78253 11.416 9.41853 10.984 9.19453 10.416L10.3705 9.732C10.6185 10.452 11.1665 10.812 12.0145 10.812C12.4945 10.812 12.8745 10.692 13.1545 10.452C13.4345 10.204 13.5745 9.872 13.5745 9.456C13.5745 9.04 13.4345 8.712 13.1545 8.472C12.8745 8.232 12.4945 8.112 12.0145 8.112H11.7025L11.1505 7.284L12.9625 4.896H9.44653V3.6H14.6065V4.776L12.9025 7.032Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -58,3 +58,4 @@ export 'src/editor/editor_component/editor_component.dart';
export 'src/editor/command/transform.dart';
export 'src/editor/util/util.dart';
export 'src/editor/toolbar/toolbar.dart';
export 'src/extensions/node_extensions.dart';

View file

@ -0,0 +1,18 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/items/icon_item_widget.dart';
ToolbarItem paragraphItem = ToolbarItem(
id: 'editor.paragraph',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'bulleted_list';
return IconItemWidget(
iconName: 'toolbar/bulleted_list',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.bulletedList,
onPressed: () {},
);
},
);

View file

@ -0,0 +1,38 @@
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/items/icon_item_widget.dart';
import 'package:flutter/foundation.dart';
ToolbarItem underlineItem = ToolbarItem(
id: 'editor.paragraph',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'quote';
return IconItemWidget(
iconName: 'toolbar/bold',
isHighlight: isHighlight,
tooltip:
'${AppFlowyEditorLocalizations.current.bold}${_shortcutTooltips('⌘ + B', 'CTRL + B', 'CTRL + B')}',
onPressed: () {},
);
},
);
String _shortcutTooltips(
String? macOSString,
String? windowsString,
String? linuxString,
) {
if (kIsWeb) return '';
if (Platform.isMacOS && macOSString != null) {
return '\n$macOSString';
} else if (Platform.isWindows && windowsString != null) {
return '\n$windowsString';
} else if (Platform.isLinux && linuxString != null) {
return '\n$linuxString';
}
return '';
}

View file

@ -0,0 +1,50 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/items/icon_item_widget.dart';
ToolbarItem heading1Item = ToolbarItem(
id: 'editor.h1',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'heading' && node.attributes['level'] == 1;
return IconItemWidget(
iconName: 'toolbar/h1',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.heading1,
onPressed: () {},
);
},
);
ToolbarItem heading2Item = ToolbarItem(
id: 'editor.h2',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'heading' && node.attributes['level'] == 2;
return IconItemWidget(
iconName: 'toolbar/h2',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.heading2,
onPressed: () {},
);
},
);
ToolbarItem heading3Item = ToolbarItem(
id: 'editor.h3',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'heading' && node.attributes['level'] == 3;
return IconItemWidget(
iconName: 'toolbar/h3',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.heading3,
onPressed: () {},
);
},
);

View file

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
class IconItemWidget extends StatelessWidget {
const IconItemWidget({
super.key,
this.size = const Size.square(28.0),
this.size = const Size.square(32.0),
required this.iconName,
required this.isHighlight,
this.tooltip,

View file

@ -0,0 +1,18 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/items/icon_item_widget.dart';
ToolbarItem paragraphItem = ToolbarItem(
id: 'editor.paragraph',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'numbered_list';
return IconItemWidget(
iconName: 'toolbar/numbered_list',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.numberedList,
onPressed: () {},
);
},
);

View file

@ -0,0 +1,18 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/toolbar/items/icon_item_widget.dart';
ToolbarItem paragraphItem = ToolbarItem(
id: 'editor.paragraph',
isActive: (editorState) => editorState.selection?.isSingle ?? false,
builder: (context, editorState) {
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'quote';
return IconItemWidget(
iconName: 'toolbar/quote',
isHighlight: isHighlight,
tooltip: AppFlowyEditorLocalizations.current.quote,
onPressed: () {},
);
},
);

View file

@ -1,6 +1,6 @@
export 'desktop/floating_toolbar.dart';
export 'desktop/floating_toolbar_widget.dart';
export 'items/heading_toolbar_item.dart';
export 'items/heading_toolbar_items.dart';
export 'items/paragraph_toolbar_item.dart';
export 'items/placeholder_toolbar_item.dart';

View file

@ -1,9 +1,4 @@
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/location/selection.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
extension NodeExtensions on Node {
@ -76,6 +71,26 @@ extension NodeExtensions on Node {
}
return null;
}
bool allSatisfyInSelection(
Selection selection,
bool Function(Delta delta) test,
) {
if (selection.isCollapsed) {
return false;
}
selection = selection.normalized;
var delta = this.delta;
if (delta == null) {
return false;
}
delta = delta.slice(selection.startIndex, selection.endIndex);
return test(delta);
}
}
extension NodesExtensions<T extends Node> on List<T> {
@ -90,4 +105,39 @@ extension NodesExtensions<T extends Node> on List<T> {
return this;
}
bool allSatisfyInSelection(
Selection selection,
bool Function(Delta delta) test,
) {
if (selection.isCollapsed) {
return false;
}
selection = selection.normalized;
final nodes = this.normalized;
if (nodes.length == 1) {
return nodes.first.allSatisfyInSelection(selection, test);
}
for (var i = 0; i < nodes.length; i++) {
final node = nodes[i];
var delta = node.delta;
if (delta == null) {
continue;
}
if (i == 0) {
delta = delta.slice(selection.start.offset);
} else if (i == nodes.length - 1) {
delta = delta.slice(0, selection.end.offset);
}
if (!test(delta)) {
return false;
}
}
return true;
}
}

View file

@ -91,7 +91,7 @@ void main() async {
initialText: text,
)
.addParagraph(
builder: (index) => '1.$text',
builder: (index) => Delta()..insert('1.$text'),
);
final editorState = EditorState(document: document);

View file

@ -11,7 +11,7 @@ Future<void> testFormatCharacterShortcut(
String text = 'Welcome to AppFlowy Editor 🔥!',
}) async {
final document = Document.blank().addParagraph(
builder: (index) => '$prefix$text',
builder: (index) => Delta()..insert('$prefix$text'),
);
final editorState = EditorState(document: document);

View file

@ -17,12 +17,12 @@ void main() async {
}
});
group('selection_transform.dart', () {
group('deleteSelection', () {
test('the selection is collapsed', () async {
final document = Document.blank().addParagraphs(
3,
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
builder: (index) =>
Delta()..insert('$index. Welcome to AppFlowy Editor 🔥!'),
);
final editorState = EditorState(document: document);
@ -44,7 +44,8 @@ void main() async {
test('the selection is single', () async {
final document = Document.blank().addParagraphs(
3,
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
builder: (index) =>
Delta()..insert('$index. Welcome to AppFlowy Editor 🔥!'),
);
final editorState = EditorState(document: document);
@ -66,7 +67,8 @@ void main() async {
test('the selection is not single and not collapsed - 1', () async {
final document = Document.blank().addParagraphs(
3,
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
builder: (index) =>
Delta()..insert('$index. Welcome to AppFlowy Editor 🔥!'),
);
final editorState = EditorState(document: document);
@ -107,17 +109,20 @@ void main() async {
// 0.1. Welcome to AppFlowy Editor 🔥!
test('the selection is not single and not collapsed - 2', () async {
final document = Document.blank().addParagraph(
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
builder: (index) =>
Delta()..insert('$index. Welcome to AppFlowy Editor 🔥!'),
decorator: (index, node) {
node.addParagraphs(
2,
builder: (index2) =>
'$index.$index2. Welcome to AppFlowy Editor 🔥!',
builder: (index2) => Delta()
..insert('$index.$index2. Welcome to AppFlowy Editor 🔥!'),
decorator: (index2, node2) {
if (index2 == 0) {
node2.addParagraph(
builder: (index3) =>
builder: (index3) => Delta()
..insert(
'$index.$index2.$index3. Welcome to AppFlowy Editor 🔥!',
),
);
}
},
@ -159,5 +164,4 @@ void main() async {
);
});
});
});
}

View file

@ -17,7 +17,6 @@ void main() async {
}
});
group('text_transform.dart', () {
group('insertNewLine', () {
const text = 'Welcome to AppFlowy Editor 🔥!';
@ -83,5 +82,4 @@ void main() async {
expect(editorState.getNodeAtPath([1, 0])?.delta?.toPlainText(), text);
});
});
});
}

View file

@ -0,0 +1,211 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import '../util/util.dart';
void main() async {
setUpAll(() {
if (kDebugMode) {
activateLog();
}
});
tearDownAll(() {
if (kDebugMode) {
deactivateLog();
}
});
group('allSatisfyInSelection - node', () {
const welcome = 'Welcome ';
const toAppFlowy = 'to AppFlowy';
const editor = ' Editor 🔥!';
// Welcome <b>|to AppFlowy</b> Editor 🔥!
test('the selection is collapsed', () async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()
..insert(welcome)
..insert(
toAppFlowy,
attributes: {
'bold': true,
},
)
..insert(editor),
);
final editorState = EditorState(document: document);
// Welcome |to AppFlowy| Editor 🔥!
final selection = Selection.collapse(
[0],
welcome.length,
);
editorState.selection = selection;
final node = editorState.getNodeAtPath([0]);
final result = node!.allSatisfyInSelection(selection, (delta) {
final textInserts = delta.whereType<TextInsert>();
return textInserts
.every((element) => element.attributes?['bold'] == true);
});
expect(result, false);
});
// Welcome |<b>to AppFlowy</b>| Editor 🔥!
test('the selection is single and not collapsed - 1', () async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()
..insert(welcome)
..insert(
toAppFlowy,
attributes: {
'bold': true,
},
)
..insert(editor),
);
final editorState = EditorState(document: document);
// Welcome |to AppFlowy| Editor 🔥!
final selection = Selection.single(
path: [0],
startOffset: welcome.length,
endOffset: welcome.length + toAppFlowy.length,
);
editorState.selection = selection;
final node = editorState.getNodeAtPath([0]);
final result = node!.allSatisfyInSelection(selection, (delta) {
final textInserts = delta.whereType<TextInsert>();
return textInserts
.every((element) => element.attributes?['bold'] == true);
});
expect(result, true);
});
// |Welcome <b>to AppFlowy</b>| Editor 🔥!
test('the selection is single and not collapsed - 2', () async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()
..insert(welcome)
..insert(
toAppFlowy,
attributes: {
'bold': true,
},
)
..insert(editor),
);
final editorState = EditorState(document: document);
// Welcome |to AppFlowy| Editor 🔥!
final selection = Selection.single(
path: [0],
startOffset: 0,
endOffset: welcome.length + toAppFlowy.length,
);
editorState.selection = selection;
final node = editorState.getNodeAtPath([0]);
final result = node!.allSatisfyInSelection(selection, (delta) {
final textInserts = delta.whereType<TextInsert>();
return textInserts
.every((element) => element.attributes?['bold'] == true);
});
expect(result, false);
});
});
group('allSatisfyInSelection - nodes', () {
const welcome = 'Welcome ';
const toAppFlowy = 'to AppFlowy';
const editor = ' Editor 🔥!';
// Welcome <b>|to AppFlowy Editor 🔥!</b>
// <b>Welcome to AppFlowy|</b> Editor 🔥!
test('the selection is not collapsed and not single - 1', () async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()
..insert(welcome)
..insert(
toAppFlowy + editor,
attributes: {
'bold': true,
},
),
)..addParagraph(
builder: (index) => Delta()
..insert(
welcome + toAppFlowy,
attributes: {
'bold': true,
},
)
..insert(
editor,
),
);
final editorState = EditorState(document: document);
// Welcome <b>|to AppFlowy Editor 🔥!</b>
// <b>Welcome to AppFlowy|</b> Editor 🔥!
final selection = Selection(
start: Position(path: [0], offset: welcome.length),
end: Position(path: [1], offset: welcome.length + toAppFlowy.length),
);
editorState.selection = selection;
final nodes = editorState.getNodesInSelection(selection);
final result = nodes.allSatisfyInSelection(selection, (delta) {
final textInserts = delta.whereType<TextInsert>();
return textInserts
.every((element) => element.attributes?['bold'] == true);
});
expect(result, true);
});
// |Welcome <b>to AppFlowy Editor 🔥!</b>
// <b>Welcome to AppFlowy</b> Editor 🔥!|
test('the selection is not collapsed and not single - 2', () async {
final document = Document.blank().addParagraph(
builder: (index) => Delta()
..insert(welcome)
..insert(
toAppFlowy + editor,
attributes: {
'bold': true,
},
),
)..addParagraph(
builder: (index) => Delta()
..insert(
welcome + toAppFlowy,
attributes: {
'bold': true,
},
)
..insert(
editor,
),
);
final editorState = EditorState(document: document);
// |Welcome <b>to AppFlowy Editor 🔥!</b>
// <b>Welcome to AppFlowy</b> Editor 🔥!|
final selection = Selection(
start: Position(path: [0], offset: 0),
end: Position(
path: [1],
offset: welcome.length + toAppFlowy.length + editor.length,
),
);
editorState.selection = selection;
final nodes = editorState.getNodesInSelection(selection);
final result = nodes.allSatisfyInSelection(selection, (delta) {
final textInserts = delta.whereType<TextInsert>();
return textInserts
.every((element) => element.attributes?['bold'] == true);
});
expect(result, false);
});
});
}

View file

@ -32,6 +32,7 @@ class TestableEditor {
'bulleted_list': BulletedListBlockComponentBuilder(),
'numbered_list': NumberedListBlockComponentBuilder(),
'quote': QuoteBlockComponentBuilder(),
'heading': HeadingBlockComponentBuilder(),
},
characterShortcutEvents: [
insertNewLine,
@ -39,6 +40,7 @@ class TestableEditor {
formatMinusToBulletedList,
formatNumberToNumberedList,
formatGreaterToQuote,
formatSignToHeading,
slashCommand,
formatUnderscoreToItalic,
],

View file

@ -10,13 +10,14 @@ extension DocumentExtension on Document {
NodeDecorator? decorator,
}) {
final builder0 = builder ??
(index) => initialText ?? '🔥 $index. Welcome to AppFlowy Editor!';
(index) => Delta()
..insert(initialText ?? '🔥 $index. Welcome to AppFlowy Editor!');
final decorator0 = decorator ?? (index, node) {};
final children = List.generate(count, (index) {
final node = Node(type: 'paragraph');
decorator0(index, node);
node.updateAttributes({
'delta': (Delta()..insert(builder0(index))).toJson(),
'delta': builder0(index).toJson(),
});
return node;
});

View file

@ -10,7 +10,8 @@ extension NodeExtension on Node {
NodeDecorator? decorator,
}) {
final builder0 = builder ??
(index) => initialText ?? '🔥 $index. Welcome to AppFlowy Editor!';
(index) => Delta()
..insert(initialText ?? '🔥 $index. Welcome to AppFlowy Editor!');
final decorator0 = decorator ?? (index, node) {};
final nodes = List.generate(
count,
@ -18,7 +19,7 @@ extension NodeExtension on Node {
final node = Node(type: 'paragraph');
decorator0(index, node);
node.updateAttributes({
'delta': (Delta()..insert(builder0(index))).toJson(),
'delta': builder0(index).toJson(),
});
return node;
},

View file

@ -4,7 +4,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
typedef DeltaBuilder = Delta Function(int index);
/// customize the initial text
typedef TextBuilder = String Function(int index);
typedef TextBuilder = Delta Function(int index);
/// customize the node
typedef NodeDecorator = void Function(int index, Node node);