feat: implement toolbar
This commit is contained in:
parent
e64d829012
commit
23350d21fb
21 changed files with 277 additions and 43 deletions
4
assets/images/toolbar/text.svg
Normal file
4
assets/images/toolbar/text.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.15625 11.8359L6.43768 9.85414H2.46662L1.74805 11.8359H0.5L3.7903 3H5.11399L8.4043 11.8359H7.15625ZM2.87003 8.75596H6.03427L4.44584 4.40112L2.87003 8.75596Z" fill="#333333"/>
|
||||||
|
<path d="M14.4032 5.52454H15.5V11.8359H14.4032V10.7504C13.8569 11.5835 13.0627 12 12.0206 12C11.1381 12 10.386 11.6802 9.76403 11.0407C9.14211 10.3927 8.83114 9.60589 8.83114 8.68022C8.83114 7.75456 9.14211 6.97195 9.76403 6.3324C10.386 5.68443 11.1381 5.36045 12.0206 5.36045C13.0627 5.36045 13.8569 5.777 14.4032 6.6101V5.52454ZM12.1593 10.9397C12.798 10.9397 13.3317 10.7251 13.7603 10.2959C14.1889 9.85835 14.4032 9.31978 14.4032 8.68022C14.4032 8.04067 14.1889 7.50631 13.7603 7.07714C13.3317 6.63955 12.798 6.42076 12.1593 6.42076C11.5289 6.42076 10.9995 6.63955 10.5708 7.07714C10.1422 7.50631 9.92791 8.04067 9.92791 8.68022C9.92791 9.31978 10.1422 9.85835 10.5708 10.2959C10.9995 10.7251 11.5289 10.9397 12.1593 10.9397Z" fill="#333333"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -37,6 +37,13 @@ class SimpleEditor extends StatelessWidget {
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
if (PlatformExtension.isDesktopOrWeb) {
|
if (PlatformExtension.isDesktopOrWeb) {
|
||||||
return FloatingToolbar(
|
return FloatingToolbar(
|
||||||
|
items: [
|
||||||
|
paragraphItem,
|
||||||
|
heading1Item,
|
||||||
|
heading2Item,
|
||||||
|
heading3Item,
|
||||||
|
placeholderItem,
|
||||||
|
],
|
||||||
editorState: editorState,
|
editorState: editorState,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
child: _buildEditor(
|
child: _buildEditor(
|
||||||
|
|
@ -75,13 +82,6 @@ class SimpleEditor extends StatelessWidget {
|
||||||
themeData: themeData,
|
themeData: themeData,
|
||||||
autoFocus: editorState.document.isEmpty,
|
autoFocus: editorState.document.isEmpty,
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
// customBuilders: {
|
|
||||||
// 'paragraph': TextBlockComponentBuilder(),
|
|
||||||
// 'todo_list': TodoListBlockComponentBuilder(),
|
|
||||||
// 'bulleted_list': BulletedListBlockComponentBuilder(),
|
|
||||||
// 'numbered_list': NumberedListBlockComponentBuilder(),
|
|
||||||
// 'quote': QuoteBlockComponentBuilder(),
|
|
||||||
// },
|
|
||||||
blockComponentBuilders: {
|
blockComponentBuilders: {
|
||||||
'document': DocumentComponentBuilder(),
|
'document': DocumentComponentBuilder(),
|
||||||
'paragraph': TextBlockComponentBuilder(),
|
'paragraph': TextBlockComponentBuilder(),
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,6 @@ export 'src/service/internal_key_event_handlers/copy_paste_handler.dart';
|
||||||
|
|
||||||
export 'src/editor/block_component/block_component.dart';
|
export 'src/editor/block_component/block_component.dart';
|
||||||
export 'src/editor/editor_component/editor_component.dart';
|
export 'src/editor/editor_component/editor_component.dart';
|
||||||
export 'src/editor/transform/transform.dart';
|
export 'src/editor/command/transform.dart';
|
||||||
export 'src/editor/util/util.dart';
|
export 'src/editor/util/util.dart';
|
||||||
|
export 'src/editor/toolbar/toolbar.dart';
|
||||||
|
|
|
||||||
226
lib/src/editor/command/selection_commands.dart
Normal file
226
lib/src/editor/command/selection_commands.dart
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
enum SelectionMoveRange {
|
||||||
|
character,
|
||||||
|
word,
|
||||||
|
line,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SelectionMoveDirection {
|
||||||
|
forward,
|
||||||
|
backward,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SelectionTransform on EditorState {
|
||||||
|
/// Deletes the selection.
|
||||||
|
///
|
||||||
|
/// If the selection is collapsed, this function does nothing.
|
||||||
|
///
|
||||||
|
/// If the node contains a delta, this function deletes the text in the selection,
|
||||||
|
/// else the node does not contain a delta, this function deletes the node.
|
||||||
|
///
|
||||||
|
/// If both the first node and last node contain a delta,
|
||||||
|
/// this function merges the delta of the last node into the first node,
|
||||||
|
/// and deletes the nodes in between.
|
||||||
|
///
|
||||||
|
/// If only the first node contains a delta,
|
||||||
|
/// this function deletes the text in the first node,
|
||||||
|
/// and deletes the nodes expect for the first node.
|
||||||
|
///
|
||||||
|
/// For the other cases, this function just deletes all the nodes.
|
||||||
|
Future<bool> deleteSelection(Selection selection) async {
|
||||||
|
// Nothing to do if the selection is collapsed.
|
||||||
|
if (selection.isCollapsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the selection so that it is never reversed or extended.
|
||||||
|
selection = selection.normalized;
|
||||||
|
|
||||||
|
// Start a new transaction.
|
||||||
|
final transaction = this.transaction;
|
||||||
|
|
||||||
|
// Get the nodes that are fully or partially selected.
|
||||||
|
final nodes = getNodesInSelection(selection);
|
||||||
|
|
||||||
|
// If only one node is selected, then we can just delete the selected text
|
||||||
|
// or node.
|
||||||
|
if (nodes.length == 1) {
|
||||||
|
final node = nodes.first;
|
||||||
|
if (node.delta != null) {
|
||||||
|
transaction.deleteText(
|
||||||
|
node,
|
||||||
|
selection.startIndex,
|
||||||
|
selection.length,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
transaction.deleteNode(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, multiple nodes are selected, so we have to do more work.
|
||||||
|
else {
|
||||||
|
// The nodes are guaranteed to be in order, so we can determine which
|
||||||
|
// nodes are at the beginning, middle, and end of the selection.
|
||||||
|
assert(nodes.first.path < nodes.last.path);
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
final node = nodes[i];
|
||||||
|
|
||||||
|
// The first node is at the beginning of the selection.
|
||||||
|
if (i == 0) {
|
||||||
|
// If the last node is also a text node, then we can merge the text
|
||||||
|
// between the two nodes.
|
||||||
|
if (nodes.last.delta != null) {
|
||||||
|
transaction.mergeText(
|
||||||
|
node,
|
||||||
|
nodes.last,
|
||||||
|
leftOffset: selection.startIndex,
|
||||||
|
rightOffset: selection.endIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// combine the children of the last node into the first node.
|
||||||
|
final last = nodes.last;
|
||||||
|
|
||||||
|
if (last.children.isNotEmpty) {
|
||||||
|
transaction.insertNodes(
|
||||||
|
node.path + [0],
|
||||||
|
last.children,
|
||||||
|
deepCopy: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we can just delete the selected text.
|
||||||
|
else {
|
||||||
|
transaction.deleteText(
|
||||||
|
node,
|
||||||
|
selection.startIndex,
|
||||||
|
selection.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other nodes can be deleted.
|
||||||
|
else {
|
||||||
|
transaction.deleteNode(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the selection is deleted, we want to move the selection to the
|
||||||
|
// beginning of the deleted selection.
|
||||||
|
transaction.afterSelection = selection.collapse(atStart: true);
|
||||||
|
Log.editor
|
||||||
|
.debug(transaction.operations.map((e) => e.toString()).toString());
|
||||||
|
|
||||||
|
// Apply the transaction.
|
||||||
|
await apply(transaction);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// move the cursor forward.
|
||||||
|
///
|
||||||
|
/// TODO: I think we should add move forward function to the SelectableMixin.
|
||||||
|
/// Don't hardcode the logic here.
|
||||||
|
/// For example,
|
||||||
|
/// final position = node.selectable?.moveForward(selection.startIndex);
|
||||||
|
/// if (position == null) { ... // move to the previous node}
|
||||||
|
/// else { ... // move to the position }
|
||||||
|
void moveCursorForward([
|
||||||
|
SelectionMoveRange range = SelectionMoveRange.character,
|
||||||
|
]) {
|
||||||
|
return moveCursor(SelectionMoveDirection.forward, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// move the cursor backward.
|
||||||
|
void moveCursorBackward(SelectionMoveRange range) {
|
||||||
|
return moveCursor(SelectionMoveDirection.backward, range);
|
||||||
|
}
|
||||||
|
|
||||||
|
void moveCursor(
|
||||||
|
SelectionMoveDirection direction, [
|
||||||
|
SelectionMoveRange range = SelectionMoveRange.character,
|
||||||
|
]) {
|
||||||
|
final selection = this.selection?.normalized;
|
||||||
|
if (selection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the selection is not collapsed, then we want to collapse the selection
|
||||||
|
if (!selection.isCollapsed) {
|
||||||
|
// move the cursor to the start or end of the selection
|
||||||
|
this.selection = selection.collapse(
|
||||||
|
atStart: direction == SelectionMoveDirection.forward,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final node = getNodeAtPath(selection.start.path);
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Originally, I want to make this function as pure as possible,
|
||||||
|
// but I have to import the selectable here to compute the selection.
|
||||||
|
final start = node.selectable?.start();
|
||||||
|
final end = node.selectable?.end();
|
||||||
|
final offset = direction == SelectionMoveDirection.forward
|
||||||
|
? selection.startIndex
|
||||||
|
: selection.endIndex;
|
||||||
|
|
||||||
|
// the cursor is at the start of the node
|
||||||
|
// move the cursor to the end of the previous node
|
||||||
|
if (direction == SelectionMoveDirection.forward &&
|
||||||
|
start != null &&
|
||||||
|
start.offset >= offset) {
|
||||||
|
final previousEnd = node
|
||||||
|
.previousNodeWhere((element) => element.selectable != null)
|
||||||
|
?.selectable
|
||||||
|
?.end();
|
||||||
|
if (previousEnd != null) {
|
||||||
|
updateSelectionWithReason(
|
||||||
|
Selection.collapsed(previousEnd),
|
||||||
|
reason: SelectionUpdateReason.uiEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// the cursor is at the end of the node
|
||||||
|
// move the cursor to the start of the next node
|
||||||
|
else if (direction == SelectionMoveDirection.backward &&
|
||||||
|
end != null &&
|
||||||
|
end.offset <= offset) {
|
||||||
|
final nextStart = node.next?.selectable?.start();
|
||||||
|
if (nextStart != null) {
|
||||||
|
updateSelectionWithReason(
|
||||||
|
Selection.collapsed(nextStart),
|
||||||
|
reason: SelectionUpdateReason.uiEvent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case SelectionMoveRange.character:
|
||||||
|
final delta = node.delta;
|
||||||
|
if (delta != null) {
|
||||||
|
// move the cursor to the left or right by one character
|
||||||
|
updateSelectionWithReason(
|
||||||
|
Selection.collapsed(
|
||||||
|
selection.start.copyWith(
|
||||||
|
offset: direction == SelectionMoveDirection.forward
|
||||||
|
? delta.prevRunePosition(offset)
|
||||||
|
: delta.nextRunePosition(offset),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
reason: SelectionUpdateReason.uiEvent,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
lib/src/editor/command/text_commands.dart
Normal file
116
lib/src/editor/command/text_commands.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
|
||||||
|
extension TextTransforms on EditorState {
|
||||||
|
/// Inserts a new line at the given position.
|
||||||
|
///
|
||||||
|
/// If the [Position] is not passed in, use the current selection.
|
||||||
|
/// If there is no position, or if the selection is not collapsed, do nothing.
|
||||||
|
///
|
||||||
|
/// Then it inserts a new paragraph node. After that, it sets the selection to be at the
|
||||||
|
/// beginning of the new paragraph.
|
||||||
|
Future<void> insertNewLine({
|
||||||
|
Position? position,
|
||||||
|
}) async {
|
||||||
|
// If the position is not passed in, use the current selection.
|
||||||
|
position = position ?? selection?.start;
|
||||||
|
|
||||||
|
// If there is no position, or if the selection is not collapsed, do nothing.
|
||||||
|
if (position == null || !(selection?.isCollapsed ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final node = getNodeAtPath(position.path);
|
||||||
|
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction and the path of the next node.
|
||||||
|
final transaction = this.transaction;
|
||||||
|
final next = position.path.next;
|
||||||
|
final children = node.children;
|
||||||
|
final delta = node.delta;
|
||||||
|
|
||||||
|
if (delta != null) {
|
||||||
|
// Delete the text after the cursor in the current node.
|
||||||
|
transaction.deleteText(
|
||||||
|
node,
|
||||||
|
position.offset,
|
||||||
|
delta.length - position.offset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the current node's children if it is not empty.
|
||||||
|
if (children.isNotEmpty) {
|
||||||
|
transaction.deleteNodes(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a new paragraph node.
|
||||||
|
transaction.insertNode(
|
||||||
|
next,
|
||||||
|
node.copyWith(
|
||||||
|
type: 'paragraph',
|
||||||
|
attributes: {
|
||||||
|
'delta':
|
||||||
|
(delta == null ? Delta() : delta.slice(position.offset)).toJson(),
|
||||||
|
}, // move the current node's children to the new paragraph node if it has any.
|
||||||
|
),
|
||||||
|
deepCopy: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the selection to be at the beginning of the new paragraph.
|
||||||
|
transaction.afterSelection = Selection.collapsed(
|
||||||
|
Position(
|
||||||
|
path: next,
|
||||||
|
offset: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply the transaction.
|
||||||
|
await apply(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts text at the given position.
|
||||||
|
/// If the [Position] is not passed in, use the current selection.
|
||||||
|
/// If there is no position, or if the selection is not collapsed, do nothing.
|
||||||
|
/// Then it inserts the text at the given position.
|
||||||
|
|
||||||
|
Future<void> insertTextAtPosition(
|
||||||
|
String text, {
|
||||||
|
Position? position,
|
||||||
|
}) async {
|
||||||
|
// If the position is not passed in, use the current selection.
|
||||||
|
position = position ?? selectionService.currentSelection.value?.start;
|
||||||
|
|
||||||
|
// If there is no position, or if the selection is not collapsed, do nothing.
|
||||||
|
if (position == null ||
|
||||||
|
!(selectionService.currentSelection.value?.isCollapsed ?? false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction and the path of the next node.
|
||||||
|
final transaction = this.transaction;
|
||||||
|
final path = position.path;
|
||||||
|
final node = getNodeAtPath(path);
|
||||||
|
final delta = node?.delta;
|
||||||
|
|
||||||
|
if (node == null || delta == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the text at the given position.
|
||||||
|
transaction.insertText(node, position.offset, text);
|
||||||
|
|
||||||
|
// Set the selection to be at the beginning of the new paragraph.
|
||||||
|
transaction.afterSelection = Selection.collapsed(
|
||||||
|
Position(
|
||||||
|
path: path,
|
||||||
|
offset: position.offset + text.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply the transaction.
|
||||||
|
await apply(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
lib/src/editor/command/transform.dart
Normal file
2
lib/src/editor/command/transform.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export 'text_commands.dart';
|
||||||
|
export 'selection_commands.dart';
|
||||||
|
|
@ -14,7 +14,7 @@ export 'service/selection_service_widget.dart';
|
||||||
|
|
||||||
// toolbar
|
// toolbar
|
||||||
export '../toolbar/mobile_toolbar.dart';
|
export '../toolbar/mobile_toolbar.dart';
|
||||||
export '../toolbar/floating_toolbar.dart';
|
export '../toolbar/desktop/floating_toolbar.dart';
|
||||||
|
|
||||||
// renderer
|
// renderer
|
||||||
export 'service/renderer/block_component_widget.dart';
|
export 'service/renderer/block_component_widget.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/character_shortcut_event.dart';
|
import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/character_shortcut_event.dart';
|
||||||
import 'package:appflowy_editor/src/editor/transform/transform.dart';
|
import 'package:appflowy_editor/src/editor/command/transform.dart';
|
||||||
import 'package:appflowy_editor/src/editor/util/util.dart';
|
import 'package:appflowy_editor/src/editor/util/util.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
|
|
||||||
214
lib/src/editor/toolbar/desktop/floating_toolbar.dart
Normal file
214
lib/src/editor/toolbar/desktop/floating_toolbar.dart
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A floating toolbar that displays at the top of the editor when the selection
|
||||||
|
/// and will be hidden when the selection is collapsed.
|
||||||
|
///
|
||||||
|
class FloatingToolbar extends StatefulWidget {
|
||||||
|
const FloatingToolbar({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.editorState,
|
||||||
|
required this.scrollController,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ToolbarItem> items;
|
||||||
|
final EditorState editorState;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingToolbar> createState() => _FloatingToolbarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingToolbarState extends State<FloatingToolbar> {
|
||||||
|
OverlayEntry? _toolbarContainer;
|
||||||
|
FloatingToolbarWidget? _toolbarWidget;
|
||||||
|
|
||||||
|
EditorState get editorState => widget.editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
editorState.selectionNotifier.addListener(_onSelectionChanged);
|
||||||
|
widget.scrollController.addListener(_onScrollPositionChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(FloatingToolbar oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (widget.editorState != oldWidget.editorState) {
|
||||||
|
editorState.selectionNotifier.addListener(_onSelectionChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.scrollController != oldWidget.scrollController) {
|
||||||
|
widget.scrollController.addListener(_onScrollPositionChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
editorState.selectionNotifier.removeListener(_onSelectionChanged);
|
||||||
|
widget.scrollController.removeListener(_onScrollPositionChanged);
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reassemble() {
|
||||||
|
super.reassemble();
|
||||||
|
|
||||||
|
_clear();
|
||||||
|
_toolbarWidget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionChanged() {
|
||||||
|
final selection = editorState.selection;
|
||||||
|
if (selection == null || selection.isCollapsed) {
|
||||||
|
_clear();
|
||||||
|
} else {
|
||||||
|
// uses debounce to avoid the computing the rects too frequently.
|
||||||
|
_showAfterDelay(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear the toolbar widget
|
||||||
|
_toolbarWidget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScrollPositionChanged() {
|
||||||
|
final offset = widget.scrollController.offset;
|
||||||
|
Log.toolbar.debug('offset = $offset');
|
||||||
|
|
||||||
|
_clear();
|
||||||
|
|
||||||
|
// TODO: optimize the toolbar showing logic, making it more smooth.
|
||||||
|
// A quick idea: based on the scroll controller's offset to display the toolbar.
|
||||||
|
_showAfterDelay(Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String _debounceKey = 'show the toolbar';
|
||||||
|
void _clear() {
|
||||||
|
Debounce.cancel(_debounceKey);
|
||||||
|
|
||||||
|
_toolbarContainer?.remove();
|
||||||
|
_toolbarContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAfterDelay([Duration duration = Duration.zero]) {
|
||||||
|
_clear(); // clear the previous toolbar
|
||||||
|
|
||||||
|
// uses debounce to avoid the computing the rects too frequently.
|
||||||
|
Debounce.debounce(
|
||||||
|
_debounceKey,
|
||||||
|
duration,
|
||||||
|
() {
|
||||||
|
_showToolbar();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showToolbar() {
|
||||||
|
final rects = _computeSelectionRects();
|
||||||
|
if (rects.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final offset = _findSuitableOffset(rects.map((e) => e.topLeft));
|
||||||
|
_toolbarContainer = OverlayEntry(
|
||||||
|
builder: (context) {
|
||||||
|
return Positioned(
|
||||||
|
left: offset.dx,
|
||||||
|
top: max(0, offset.dy) - 30,
|
||||||
|
child: _buildToolbar(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Overlay.of(context).insert(_toolbarContainer!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildToolbar(BuildContext context) {
|
||||||
|
_toolbarWidget ??=
|
||||||
|
FloatingToolbarWidget(items: widget.items, editorState: editorState);
|
||||||
|
return _toolbarWidget!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the rects of the selection.
|
||||||
|
List<Rect> _computeSelectionRects() {
|
||||||
|
final selection = editorState.selection;
|
||||||
|
if (selection == null || selection.isCollapsed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodes = editorState.getNodesInSelection(selection);
|
||||||
|
final rects = <Rect>[];
|
||||||
|
for (final node in nodes) {
|
||||||
|
final selectable = node.selectable;
|
||||||
|
if (selectable == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final nodeRects = selectable.getRectsInSelection(selection);
|
||||||
|
if (nodeRects.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final renderBox = node.renderBox;
|
||||||
|
if (renderBox == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (final rect in nodeRects) {
|
||||||
|
final globalOffset = renderBox.localToGlobal(rect.topLeft);
|
||||||
|
rects.add(globalOffset & rect.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
final rects = nodes
|
||||||
|
.map(
|
||||||
|
(node) => node.selectable
|
||||||
|
?.getRectsInSelection(selection)
|
||||||
|
.map(
|
||||||
|
(rect) => node.renderBox?.localToGlobal(rect.topLeft),
|
||||||
|
)
|
||||||
|
.whereNotNull(),
|
||||||
|
)
|
||||||
|
.whereNotNull()
|
||||||
|
.expand((element) => element)
|
||||||
|
.toList();
|
||||||
|
*/
|
||||||
|
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _findSuitableOffset(Iterable<Offset> offsets) {
|
||||||
|
assert(offsets.isNotEmpty);
|
||||||
|
|
||||||
|
// find the min offset with non-negative dy.
|
||||||
|
final offsetsWithNonNegativeDy =
|
||||||
|
offsets.where((element) => element.dy >= 30);
|
||||||
|
if (offsetsWithNonNegativeDy.isEmpty) {
|
||||||
|
// if all the rects offset is negative, then the selection is not visible.
|
||||||
|
return offsets.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
final minOffset = offsetsWithNonNegativeDy.reduce((min, current) {
|
||||||
|
if (min.dy < current.dy) {
|
||||||
|
return min;
|
||||||
|
} else if (min.dy == current.dy) {
|
||||||
|
return min.dx < current.dx ? min : current;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return minOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart
Normal file
50
lib/src/editor/toolbar/desktop/floating_toolbar_widget.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FloatingToolbarWidget extends StatefulWidget {
|
||||||
|
const FloatingToolbarWidget({
|
||||||
|
super.key,
|
||||||
|
this.backgroundColor = Colors.black,
|
||||||
|
required this.items,
|
||||||
|
required this.editorState,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ToolbarItem> items;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final EditorState editorState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingToolbarWidget> createState() => _FloatingToolbarWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingToolbarWidgetState extends State<FloatingToolbarWidget> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final activeItems = widget.items.where(
|
||||||
|
(element) => element.isActive?.call(widget.editorState) ?? false,
|
||||||
|
);
|
||||||
|
if (activeItems.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Material(
|
||||||
|
borderRadius: BorderRadius.circular(8.0),
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 32.0,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: activeItems.map((item) {
|
||||||
|
final builder = item.builder;
|
||||||
|
return Center(
|
||||||
|
child: builder!(context, widget.editorState),
|
||||||
|
);
|
||||||
|
}).toList(growable: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/src/editor/toolbar/items/heading_toolbar_item.dart
Normal file
50
lib/src/editor/toolbar/items/heading_toolbar_item.dart
Normal 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: () {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
54
lib/src/editor/toolbar/items/icon_item_widget.dart
Normal file
54
lib/src/editor/toolbar/items/icon_item_widget.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class IconItemWidget extends StatelessWidget {
|
||||||
|
const IconItemWidget({
|
||||||
|
super.key,
|
||||||
|
this.size = const Size.square(28.0),
|
||||||
|
required this.iconName,
|
||||||
|
required this.isHighlight,
|
||||||
|
this.tooltip,
|
||||||
|
this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Size size;
|
||||||
|
final String iconName;
|
||||||
|
final bool isHighlight;
|
||||||
|
final String? tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Widget child = FlowySvg(
|
||||||
|
name: iconName,
|
||||||
|
color: isHighlight ? Colors.lightBlue : null,
|
||||||
|
);
|
||||||
|
if (onPressed != null) {
|
||||||
|
child = MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: IconButton(
|
||||||
|
hoverColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: child,
|
||||||
|
iconSize: size.width,
|
||||||
|
onPressed: onPressed,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tooltip != null) {
|
||||||
|
child = Tooltip(
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
preferBelow: false,
|
||||||
|
message: tooltip,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/src/editor/toolbar/items/paragraph_toolbar_item.dart
Normal file
18
lib/src/editor/toolbar/items/paragraph_toolbar_item.dart
Normal 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 == 'paragraph';
|
||||||
|
return IconItemWidget(
|
||||||
|
iconName: 'toolbar/text',
|
||||||
|
isHighlight: isHighlight,
|
||||||
|
tooltip: AppFlowyEditorLocalizations.current.text,
|
||||||
|
onPressed: () {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
16
lib/src/editor/toolbar/items/placeholder_toolbar_item.dart
Normal file
16
lib/src/editor/toolbar/items/placeholder_toolbar_item.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
ToolbarItem placeholderItem = ToolbarItem(
|
||||||
|
id: 'editor.placeholder',
|
||||||
|
isActive: (editorState) => true,
|
||||||
|
builder: (_, __) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 5),
|
||||||
|
child: Container(
|
||||||
|
width: 1,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
6
lib/src/editor/toolbar/toolbar.dart
Normal file
6
lib/src/editor/toolbar/toolbar.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export 'desktop/floating_toolbar.dart';
|
||||||
|
export 'desktop/floating_toolbar_widget.dart';
|
||||||
|
|
||||||
|
export 'items/heading_toolbar_item.dart';
|
||||||
|
export 'items/paragraph_toolbar_item.dart';
|
||||||
|
export 'items/placeholder_toolbar_item.dart';
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export 'selection_transform.dart';
|
|
||||||
export 'text_transform.dart';
|
|
||||||
|
|
@ -18,25 +18,32 @@ typedef ToolbarItemHighlightCallback = bool Function(EditorState editorState);
|
||||||
class ToolbarItem {
|
class ToolbarItem {
|
||||||
ToolbarItem({
|
ToolbarItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.type,
|
this.type = 1,
|
||||||
this.tooltipsMessage = '',
|
this.tooltipsMessage = '',
|
||||||
this.iconBuilder,
|
this.iconBuilder,
|
||||||
required this.validator,
|
this.validator,
|
||||||
this.highlightCallback,
|
this.highlightCallback,
|
||||||
this.handler,
|
this.handler,
|
||||||
this.itemBuilder,
|
this.itemBuilder,
|
||||||
|
this.isActive,
|
||||||
|
this.builder,
|
||||||
}) {
|
}) {
|
||||||
assert(
|
// assert(
|
||||||
(iconBuilder != null && itemBuilder == null) ||
|
// (iconBuilder != null && itemBuilder == null) ||
|
||||||
(iconBuilder == null && itemBuilder != null),
|
// (iconBuilder == null && itemBuilder != null),
|
||||||
'iconBuilder and itemBuilder must be set one of them',
|
// 'iconBuilder and itemBuilder must be set one of them',
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
final bool Function(EditorState editorState)? isActive;
|
||||||
|
final Widget Function(BuildContext context, EditorState editorState)? builder;
|
||||||
|
|
||||||
|
// deprecated
|
||||||
final int type;
|
final int type;
|
||||||
final String tooltipsMessage;
|
final String tooltipsMessage;
|
||||||
final ToolbarItemValidator validator;
|
|
||||||
|
final ToolbarItemValidator? validator;
|
||||||
|
|
||||||
final Widget Function(bool isHighlight)? iconBuilder;
|
final Widget Function(bool isHighlight)? iconBuilder;
|
||||||
final ToolbarItemEventHandler? handler;
|
final ToolbarItemEventHandler? handler;
|
||||||
|
|
@ -355,7 +362,7 @@ bool _allSatisfy(
|
||||||
String styleKey,
|
String styleKey,
|
||||||
bool Function(dynamic value) test,
|
bool Function(dynamic value) test,
|
||||||
) {
|
) {
|
||||||
final selection = editorState.service.selectionService.currentSelection.value;
|
final selection = editorState.selection;
|
||||||
return selection != null &&
|
return selection != null &&
|
||||||
editorState.selectedTextNodes.allSatisfyInSelection(
|
editorState.selectedTextNodes.allSatisfyInSelection(
|
||||||
selection,
|
selection,
|
||||||
|
|
|
||||||
|
|
@ -69,19 +69,19 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||||
children: widget.items
|
children: widget.items
|
||||||
.map(
|
.map(
|
||||||
(item) => Center(
|
(item) => Center(
|
||||||
child:
|
child: item.builder?.call(context, widget.editorState) ??
|
||||||
item.itemBuilder?.call(context, widget.editorState) ??
|
item.itemBuilder?.call(context, widget.editorState) ??
|
||||||
ToolbarItemWidget(
|
ToolbarItemWidget(
|
||||||
item: item,
|
item: item,
|
||||||
isHighlight: item.highlightCallback
|
isHighlight: item.highlightCallback
|
||||||
?.call(widget.editorState) ??
|
?.call(widget.editorState) ??
|
||||||
false,
|
false,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
item.handler?.call(widget.editorState, context);
|
item.handler?.call(widget.editorState, context);
|
||||||
widget.editorState.service.keyboardService
|
widget.editorState.service.keyboardService
|
||||||
?.enable();
|
?.enable();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||||
// and insert dividers between different types.
|
// and insert dividers between different types.
|
||||||
List<ToolbarItem> _filterItems(List<ToolbarItem> items) {
|
List<ToolbarItem> _filterItems(List<ToolbarItem> items) {
|
||||||
final filterItems = items
|
final filterItems = items
|
||||||
.where((item) => item.validator(widget.editorState))
|
.where((item) => item.validator?.call(widget.editorState) ?? false)
|
||||||
.toList(growable: false)
|
.toList(growable: false)
|
||||||
..sort((a, b) => a.type.compareTo(b.type));
|
..sort((a, b) => a.type.compareTo(b.type));
|
||||||
if (filterItems.isEmpty) {
|
if (filterItems.isEmpty) {
|
||||||
|
|
|
||||||
163
test/new/command/selection_commands_test.dart
Normal file
163
test/new/command/selection_commands_test.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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('selection_transform.dart', () {
|
||||||
|
group('deleteSelection', () {
|
||||||
|
test('the selection is collapsed', () async {
|
||||||
|
final document = Document.blank().addParagraphs(
|
||||||
|
3,
|
||||||
|
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
|
final selection = Selection.collapsed(
|
||||||
|
Position(path: [1], offset: 10),
|
||||||
|
);
|
||||||
|
final before = editorState.getNodesInSelection(selection).first;
|
||||||
|
final result = await editorState.deleteSelection(selection);
|
||||||
|
|
||||||
|
// nothing happens
|
||||||
|
expect(result, false);
|
||||||
|
final after = editorState.getNodesInSelection(selection).first;
|
||||||
|
expect(
|
||||||
|
before.toJson(),
|
||||||
|
after.toJson(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selection is single', () async {
|
||||||
|
final document = Document.blank().addParagraphs(
|
||||||
|
3,
|
||||||
|
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
|
// |Welcome|
|
||||||
|
final selection = Selection.single(
|
||||||
|
path: [1],
|
||||||
|
startOffset: 3,
|
||||||
|
endOffset: 10,
|
||||||
|
);
|
||||||
|
final result = await editorState.deleteSelection(selection);
|
||||||
|
|
||||||
|
// nothing happens
|
||||||
|
expect(result, true);
|
||||||
|
expect(editorState.selection, selection.collapse(atStart: true));
|
||||||
|
final after = editorState.getNodesInSelection(selection).first;
|
||||||
|
expect(after.delta?.toPlainText(), '1. to AppFlowy Editor 🔥!');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the selection is not single and not collapsed - 1', () async {
|
||||||
|
final document = Document.blank().addParagraphs(
|
||||||
|
3,
|
||||||
|
builder: (index) => '$index. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
|
// |Welcome
|
||||||
|
// ...
|
||||||
|
// Welcome|
|
||||||
|
final selection = Selection(
|
||||||
|
start: Position(
|
||||||
|
path: [0],
|
||||||
|
offset: 3,
|
||||||
|
),
|
||||||
|
end: Position(
|
||||||
|
path: [2],
|
||||||
|
offset: 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final result = await editorState.deleteSelection(selection);
|
||||||
|
|
||||||
|
// nothing happens
|
||||||
|
expect(result, true);
|
||||||
|
expect(editorState.selection, selection.collapse(atStart: true));
|
||||||
|
final length = editorState.document.root.children.length;
|
||||||
|
expect(length, 1);
|
||||||
|
expect(
|
||||||
|
editorState.document.nodeAtPath([0])?.delta?.toPlainText(),
|
||||||
|
'0. to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before
|
||||||
|
// 0. Welcome |to AppFlowy Editor 🔥!
|
||||||
|
// 0.0. Welcome |to AppFlowy Editor 🔥!
|
||||||
|
// 0.0.0. Welcome to AppFlowy Editor 🔥!
|
||||||
|
// 0.1. Welcome to AppFlowy Editor 🔥!
|
||||||
|
// After
|
||||||
|
// 0. Welcome to AppFlowy Editor 🔥!
|
||||||
|
// 0.0.0. Welcome to AppFlowy Editor 🔥!
|
||||||
|
// 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 🔥!',
|
||||||
|
decorator: (index, node) {
|
||||||
|
node.addParagraphs(
|
||||||
|
2,
|
||||||
|
builder: (index2) =>
|
||||||
|
'$index.$index2. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
decorator: (index2, node2) {
|
||||||
|
if (index2 == 0) {
|
||||||
|
node2.addParagraph(
|
||||||
|
builder: (index3) =>
|
||||||
|
'$index.$index2.$index3. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final editorState = EditorState(document: document);
|
||||||
|
|
||||||
|
// 0. Welcome |to AppFlowy Editor 🔥!
|
||||||
|
// 0.0. Welcome |to AppFlowy Editor 🔥!
|
||||||
|
// 0.0.0 Welcome to AppFlowy Editor 🔥!
|
||||||
|
// 0.1 Welcome to AppFlowy Editor 🔥!
|
||||||
|
final selection = Selection(
|
||||||
|
start: Position(
|
||||||
|
path: [0],
|
||||||
|
offset: '0. Welcome '.length,
|
||||||
|
),
|
||||||
|
end: Position(
|
||||||
|
path: [0, 0],
|
||||||
|
offset: '0.0. Welcome '.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final result = await editorState.deleteSelection(selection);
|
||||||
|
|
||||||
|
// nothing happens
|
||||||
|
expect(result, true);
|
||||||
|
expect(editorState.selection, selection.collapse(atStart: true));
|
||||||
|
expect(
|
||||||
|
editorState.document.nodeAtPath([0])?.delta?.toPlainText(),
|
||||||
|
'0. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
editorState.document.nodeAtPath([0, 0])?.delta?.toPlainText(),
|
||||||
|
'0.0.0. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
editorState.document.nodeAtPath([0, 1])?.delta?.toPlainText(),
|
||||||
|
'0.1. Welcome to AppFlowy Editor 🔥!',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue