Skip to content

Commit 84d55b5

Browse files
committed
fix: disable deleting mutilple nodes in table
1 parent b53e20f commit 84d55b5

File tree

2 files changed

+324
-0
lines changed

2 files changed

+324
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import 'dart:math';
2+
3+
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
4+
import 'package:appflowy_editor/appflowy_editor.dart';
5+
import 'package:flutter/material.dart';
6+
7+
/// Backspace key event.
8+
///
9+
/// - support
10+
/// - desktop
11+
/// - web
12+
/// - mobile
13+
///
14+
final CommandShortcutEvent customBackspaceCommand = CommandShortcutEvent(
15+
key: 'backspace',
16+
getDescription: () => AppFlowyEditorL10n.current.cmdDeleteLeft,
17+
command: 'backspace, shift+backspace',
18+
handler: _backspaceCommandHandler,
19+
);
20+
21+
CommandShortcutEventHandler _backspaceCommandHandler = (editorState) {
22+
final selection = editorState.selection;
23+
final selectionType = editorState.selectionType;
24+
25+
if (selection == null) {
26+
return KeyEventResult.ignored;
27+
}
28+
29+
final reason = editorState.selectionUpdateReason;
30+
31+
if (selectionType == SelectionType.block) {
32+
return _backspaceInBlockSelection(editorState);
33+
} else if (selection.isCollapsed) {
34+
return _backspaceInCollapsedSelection(editorState);
35+
} else if (reason == SelectionUpdateReason.selectAll) {
36+
return _backspaceInSelectAll(editorState);
37+
} else {
38+
return _backspaceInNotCollapsedSelection(editorState);
39+
}
40+
};
41+
42+
/// Handle backspace key event when selection is collapsed.
43+
CommandShortcutEventHandler _backspaceInCollapsedSelection = (editorState) {
44+
final selection = editorState.selection;
45+
if (selection == null || !selection.isCollapsed) {
46+
return KeyEventResult.ignored;
47+
}
48+
49+
final position = selection.start;
50+
final node = editorState.getNodeAtPath(position.path);
51+
if (node == null) {
52+
return KeyEventResult.ignored;
53+
}
54+
55+
final transaction = editorState.transaction;
56+
57+
// delete the entire node if the delta is empty
58+
if (node.delta == null) {
59+
transaction.deleteNode(node);
60+
transaction.afterSelection = Selection.collapsed(
61+
Position(
62+
path: position.path,
63+
),
64+
);
65+
editorState.apply(transaction);
66+
return KeyEventResult.handled;
67+
}
68+
69+
// Why do we use prevRunPosition instead of the position start offset?
70+
// Because some character's length > 1, for example, emoji.
71+
final index = node.delta!.prevRunePosition(position.offset);
72+
73+
if (index < 0) {
74+
// move this node to it's parent in below case.
75+
// the node's next is null
76+
// and the node's children is empty
77+
if (node.next == null &&
78+
node.children.isEmpty &&
79+
node.parent?.parent != null &&
80+
node.parent?.delta != null) {
81+
final path = node.parent!.path.next;
82+
transaction
83+
..deleteNode(node)
84+
..insertNode(path, node)
85+
..afterSelection = Selection.collapsed(
86+
Position(
87+
path: path,
88+
),
89+
);
90+
} else {
91+
// If the deletion crosses columns and starts from the beginning position
92+
// skip the node deletion process
93+
// otherwise it will cause an error in table rendering.
94+
if (node.parent?.type == SimpleTableCellBlockKeys.type &&
95+
position.offset == 0) {
96+
return KeyEventResult.handled;
97+
}
98+
99+
final Node? tableParent = node
100+
.findParent((element) => element.type == SimpleTableBlockKeys.type);
101+
Node? prevTableParent;
102+
final prev = node.previousNodeWhere((element) {
103+
prevTableParent = element
104+
.findParent((element) => element.type == SimpleTableBlockKeys.type);
105+
// break if only one is in a table or they're in different tables
106+
return tableParent != prevTableParent ||
107+
// merge with the previous node contains delta.
108+
element.delta != null;
109+
});
110+
// table nodes should be deleted using the table menu
111+
// in-table paragraphs should only be deleted inside the table
112+
if (prev != null && tableParent == prevTableParent) {
113+
assert(prev.delta != null);
114+
transaction
115+
..mergeText(prev, node)
116+
..insertNodes(
117+
// insert children to previous node
118+
prev.path.next,
119+
node.children.toList(),
120+
)
121+
..deleteNode(node)
122+
..afterSelection = Selection.collapsed(
123+
Position(
124+
path: prev.path,
125+
offset: prev.delta!.length,
126+
),
127+
);
128+
} else {
129+
// do nothing if there is no previous node contains delta.
130+
return KeyEventResult.ignored;
131+
}
132+
}
133+
} else {
134+
// Although the selection may be collapsed,
135+
// its length may not always be equal to 1 because some characters have a length greater than 1.
136+
transaction.deleteText(
137+
node,
138+
index,
139+
position.offset - index,
140+
);
141+
}
142+
143+
editorState.apply(transaction);
144+
return KeyEventResult.handled;
145+
};
146+
147+
/// Handle backspace key event when selection is not collapsed.
148+
CommandShortcutEventHandler _backspaceInNotCollapsedSelection = (editorState) {
149+
final selection = editorState.selection;
150+
if (selection == null || selection.isCollapsed) {
151+
return KeyEventResult.ignored;
152+
}
153+
editorState.deleteSelectionV2(selection);
154+
return KeyEventResult.handled;
155+
};
156+
157+
CommandShortcutEventHandler _backspaceInBlockSelection = (editorState) {
158+
final selection = editorState.selection;
159+
if (selection == null || editorState.selectionType != SelectionType.block) {
160+
return KeyEventResult.ignored;
161+
}
162+
final transaction = editorState.transaction;
163+
transaction.deleteNodesAtPath(selection.start.path);
164+
editorState
165+
.apply(transaction)
166+
.then((value) => editorState.selectionType = null);
167+
168+
return KeyEventResult.handled;
169+
};
170+
171+
CommandShortcutEventHandler _backspaceInSelectAll = (editorState) {
172+
final selection = editorState.selection;
173+
if (selection == null) {
174+
return KeyEventResult.ignored;
175+
}
176+
177+
final transaction = editorState.transaction;
178+
final nodes = editorState.getNodesInSelection(selection);
179+
transaction.deleteNodes(nodes);
180+
editorState.apply(transaction);
181+
182+
return KeyEventResult.handled;
183+
};
184+
185+
extension on EditorState {
186+
Future<bool> deleteSelectionV2(Selection selection) async {
187+
// Nothing to do if the selection is collapsed.
188+
if (selection.isCollapsed) {
189+
return false;
190+
}
191+
192+
// Normalize the selection so that it is never reversed or extended.
193+
selection = selection.normalized;
194+
195+
// Start a new transaction.
196+
final transaction = this.transaction;
197+
198+
// Get the nodes that are fully or partially selected.
199+
final nodes = getNodesInSelection(selection);
200+
201+
// If only one node is selected, then we can just delete the selected text
202+
// or node.
203+
if (nodes.length == 1) {
204+
// If table cell is selected, clear the cell node child.
205+
final node = nodes.first.type == SimpleTableCellBlockKeys.type
206+
? nodes.first.children.first
207+
: nodes.first;
208+
if (node.delta != null) {
209+
transaction.deleteText(
210+
node,
211+
selection.startIndex,
212+
selection.length,
213+
);
214+
} else if (node.parent?.type != SimpleTableCellBlockKeys.type &&
215+
node.parent?.type != SimpleTableRowBlockKeys.type) {
216+
transaction.deleteNode(node);
217+
}
218+
}
219+
220+
// Otherwise, multiple nodes are selected, so we have to do more work.
221+
else {
222+
// The nodes are guaranteed to be in order, so we can determine which
223+
// nodes are at the beginning, middle, and end of the selection.
224+
assert(nodes.first.path < nodes.last.path);
225+
for (var i = 0; i < nodes.length; i++) {
226+
final node = nodes[i];
227+
228+
// The first node is at the beginning of the selection.
229+
// All other nodes can be deleted.
230+
if (i != 0) {
231+
// Never delete a table cell node child
232+
if (node.parent?.type == SimpleTableCellBlockKeys.type) {
233+
if (!nodes.any((n) => n.id == node.parent?.parent?.id) &&
234+
node.delta != null) {
235+
transaction.deleteText(
236+
node,
237+
0,
238+
min(selection.end.offset, node.delta!.length),
239+
);
240+
}
241+
}
242+
// If first node was inside table cell then it wasn't mergable to last
243+
// node, So we should not delete the last node. Just delete part of
244+
// the text inside selection
245+
else if (node.id == nodes.last.id &&
246+
nodes.first.parent?.type == SimpleTableCellBlockKeys.type) {
247+
transaction.deleteText(
248+
node,
249+
0,
250+
selection.end.offset,
251+
);
252+
} else if (node.type != SimpleTableCellBlockKeys.type &&
253+
node.type != SimpleTableRowBlockKeys.type) {
254+
transaction.deleteNode(node);
255+
}
256+
continue;
257+
}
258+
259+
// If the last node is also a text node and not a node inside table cell,
260+
// and also the current node isn't inside table cell, then we can merge
261+
// the text between the two nodes.
262+
if (nodes.last.delta != null &&
263+
![node.parent?.type, nodes.last.parent?.type]
264+
.contains(SimpleTableCellBlockKeys.type)) {
265+
transaction.mergeText(
266+
node,
267+
nodes.last,
268+
leftOffset: selection.startIndex,
269+
rightOffset: selection.endIndex,
270+
);
271+
272+
// combine the children of the last node into the first node.
273+
final last = nodes.last;
274+
275+
if (last.children.isNotEmpty) {
276+
if (indentableBlockTypes.contains(node.type)) {
277+
transaction.insertNodes(
278+
node.path + [0],
279+
last.children,
280+
);
281+
} else {
282+
transaction.insertNodes(
283+
node.path.next,
284+
last.children,
285+
);
286+
}
287+
}
288+
}
289+
290+
// Otherwise, we can just delete the selected text.
291+
else {
292+
// If the last or first node is inside table we will only delete
293+
// selection part of first node.
294+
if (nodes.last.parent?.type == SimpleTableCellBlockKeys.type ||
295+
node.parent?.type == SimpleTableCellBlockKeys.type) {
296+
transaction.deleteText(
297+
node,
298+
selection.startIndex,
299+
node.delta!.length - selection.startIndex,
300+
);
301+
} else {
302+
transaction.deleteText(
303+
node,
304+
selection.startIndex,
305+
selection.length,
306+
);
307+
}
308+
}
309+
}
310+
}
311+
312+
// After the selection is deleted, we want to move the selection to the
313+
// beginning of the deleted selection.
314+
transaction.afterSelection = selection.collapse(atStart: true);
315+
316+
// Apply the transaction.
317+
await apply(transaction);
318+
319+
return true;
320+
}
321+
}

frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/command_shortcuts.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:appflowy/generated/locale_keys.g.dart';
22
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart';
33
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
4+
import 'package:appflowy/plugins/document/presentation/editor_plugins/shortcuts/backspace_command.dart';
45
import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart';
56
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
67
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -37,6 +38,8 @@ List<CommandShortcutEvent> commandShortcutEvents = [
3738

3839
...customTextAlignCommands,
3940

41+
customBackspaceCommand,
42+
4043
// remove standard shortcuts for copy, cut, paste, todo
4144
...standardCommandShortcutEvents
4245
..removeWhere(

0 commit comments

Comments
 (0)