Skip to content

Commit 9bb6f07

Browse files
Cherry Pick: [SuperEditor][Super Editor Clipboard] - Fix and improve paste functionality. Add rich text paste tools to super_editor_clipboard (Resolves #2883) (#2884) (#2885)
1 parent 39dd862 commit 9bb6f07

File tree

11 files changed

+1130
-16
lines changed

11 files changed

+1130
-16
lines changed

.github/workflows/pr_validation.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,46 @@ jobs:
193193
name: golden-failures
194194
path: "**/failures/**/*.png"
195195

196+
analyze_super_editor_clipboard:
197+
runs-on: ubuntu-latest
198+
defaults:
199+
run:
200+
working-directory: ./super_editor_clipboard
201+
steps:
202+
# Checkout the PR branch
203+
- uses: actions/checkout@v3
204+
205+
# Setup Flutter environment
206+
- uses: subosito/flutter-action@v2
207+
with:
208+
channel: "master"
209+
210+
# Download all the packages that the app uses
211+
- run: flutter pub get
212+
213+
# Enforce static analysis
214+
- run: flutter analyze
215+
216+
test_super_editor_clipboard:
217+
runs-on: ubuntu-latest
218+
defaults:
219+
run:
220+
working-directory: ./super_editor_clipboard
221+
steps:
222+
# Checkout the PR branch
223+
- uses: actions/checkout@v3
224+
225+
# Setup Flutter environment
226+
- uses: subosito/flutter-action@v2
227+
with:
228+
channel: "master"
229+
230+
# Download all the packages that the app uses
231+
- run: flutter pub get
232+
233+
# Run all tests
234+
- run: flutter test
235+
196236
analyze_super_keyboard:
197237
runs-on: ubuntu-latest
198238
defaults:

super_editor/lib/src/default_editor/multi_node_editing.dart

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,29 @@ class PasteStructuredContentEditorCommand extends EditCommand {
9797
return;
9898
}
9999

100-
final (upstreamNodeId, _) = _splitPasteParagraph(
101-
executor, currentNodeWithSelection.id, (pastePosition.nodePosition as TextNodePosition).offset);
100+
late final String upstreamNodeId;
101+
DocumentPosition? caretPositionAfterPaste;
102+
103+
if (currentNodeWithSelection.text.isEmpty ||
104+
(pastePosition.nodePosition as TextNodePosition).offset == currentNodeWithSelection.text.length) {
105+
// We're pasting into an empty node, or pasting at the very end of a non-empty `TextNode`.
106+
// We already know we can't combine the pasted content with this node. We'll paste below
107+
// this node.
108+
upstreamNodeId = currentNodeWithSelection.id;
109+
} else {
110+
// We're pasting into the middle of a non-empty text node. We already know we can't combine
111+
// the pasted content with this node. Split the selected node before pasting.
112+
final (splitUpstreamNodeId, splitDownstreamNodeId) = _splitPasteParagraph(
113+
executor, currentNodeWithSelection.id, (pastePosition.nodePosition as TextNodePosition).offset);
114+
upstreamNodeId = splitUpstreamNodeId;
115+
116+
// Since we split a non-empty paragraph, we'll insert the caret at the start
117+
// of the 2nd half of the split text.
118+
caretPositionAfterPaste = DocumentPosition(
119+
nodeId: splitDownstreamNodeId,
120+
nodePosition: const TextNodePosition(offset: 0),
121+
);
122+
}
102123

103124
// Insert the pasted node after the split upstream node.
104125
document.insertNodeAfter(
@@ -108,17 +129,53 @@ class PasteStructuredContentEditorCommand extends EditCommand {
108129
executor.logChanges([
109130
DocumentEdit(
110131
NodeInsertedEvent(pastedNode.id, document.getNodeIndexById(pastedNode.id)),
111-
)
132+
),
112133
]);
113134

135+
// Maybe delete the original selected node, and maybe insert empty paragraph at end.
136+
if (currentNodeWithSelection.text.isEmpty) {
137+
// We pasted content below the selected node, but the selected node was empty.
138+
// As a UX policy, let's delete that empty paragraph because a user won't expect
139+
// it to stay around.
140+
document.deleteNode(currentNodeWithSelection.id);
141+
executor.logChanges([
142+
DocumentEdit(
143+
NodeRemovedEvent(pastedNode.id, currentNodeWithSelection),
144+
),
145+
]);
146+
147+
if (pastedNode is! TextNode) {
148+
// The pasted content isn't text. It might be an image, table, etc. As a UX
149+
// policy, we insert an empty paragraph after the pasted content because users
150+
// typically expect to be able to start typing after pasting.
151+
final newNodeId = Editor.createNodeId();
152+
document.insertNodeAfter(
153+
existingNodeId: pastedNode.id,
154+
newNode: ParagraphNode(id: newNodeId, text: AttributedText()),
155+
);
156+
executor.logChanges([
157+
DocumentEdit(
158+
NodeInsertedEvent(newNodeId, document.getNodeIndexById(newNodeId)),
159+
),
160+
]);
161+
162+
caretPositionAfterPaste = DocumentPosition(nodeId: newNodeId, nodePosition: const TextNodePosition(offset: 0));
163+
}
164+
}
165+
166+
// We didn't split a non-empty paragraph, and we didn't insert a new empty paragraph
167+
// at the end of the pasted content. Therefore, place the caret at the end of the pasted
168+
// content.
169+
caretPositionAfterPaste ??= DocumentPosition(
170+
nodeId: pastedNode.id,
171+
nodePosition: pastedNode.endPosition,
172+
);
173+
114174
// Place the caret at the end of the pasted content.
115175
executor.executeCommand(
116176
ChangeSelectionCommand(
117177
DocumentSelection.collapsed(
118-
position: DocumentPosition(
119-
nodeId: pastedNode.id,
120-
nodePosition: pastedNode.endPosition,
121-
),
178+
position: caretPositionAfterPaste,
122179
),
123180
SelectionChangeType.insertContent,
124181
SelectionReason.userInteraction,
@@ -163,14 +220,16 @@ class PasteStructuredContentEditorCommand extends EditCommand {
163220

164221
// We've pasted the first new node. Remove it from the nodes to insert.
165222
nodesToInsert.removeAt(0);
166-
}
167-
if (currentNodeWithSelection.text.length == 0) {
223+
} else if (currentNodeWithSelection.text.length == 0) {
168224
// The node with the selection is an empty text node. After we use that node's
169225
// position to insert other nodes, we want to delete that first node, as if the
170226
// pasted content replaced it.
171227
deleteInitiallySelectedNode = true;
172228
}
173229

230+
// The caret position we want after the paste.
231+
DocumentPosition? pasteEndPosition;
232+
174233
// (Possibly) merge or delete the downstream split node.
175234
if (nodesToInsert.isNotEmpty) {
176235
final lastPastedNode = nodesToInsert.last;
@@ -193,6 +252,13 @@ class PasteStructuredContentEditorCommand extends EditCommand {
193252

194253
// We've pasted the last new node. Remove it from the nodes to insert.
195254
nodesToInsert.removeLast();
255+
256+
// Since we combined the last paste node with the 2nd half of the original
257+
// node, the caret position sits in the middle of that combined node.
258+
pasteEndPosition = DocumentPosition(
259+
nodeId: downstreamSplitNode.id,
260+
nodePosition: TextNodePosition(offset: lastPastedNode.text.length),
261+
);
196262
}
197263
}
198264

@@ -212,6 +278,10 @@ class PasteStructuredContentEditorCommand extends EditCommand {
212278
)
213279
]);
214280
}
281+
pasteEndPosition ??= DocumentPosition(
282+
nodeId: previousNode.id,
283+
nodePosition: previousNode.endPosition,
284+
);
215285

216286
if (deleteInitiallySelectedNode) {
217287
document.deleteNode(currentNodeWithSelection.id);
@@ -225,12 +295,7 @@ class PasteStructuredContentEditorCommand extends EditCommand {
225295
// Place the caret at the end of the pasted content.
226296
executor.executeCommand(
227297
ChangeSelectionCommand(
228-
DocumentSelection.collapsed(
229-
position: DocumentPosition(
230-
nodeId: previousNode.id,
231-
nodePosition: previousNode.endPosition,
232-
),
233-
),
298+
DocumentSelection.collapsed(position: pasteEndPosition),
234299
SelectionChangeType.insertContent,
235300
SelectionReason.userInteraction,
236301
),

super_editor/lib/src/default_editor/tables/table_block.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,37 @@ class TableBlockNode extends BlockNode {
5959
return row[columnIndex];
6060
}
6161

62+
@override
63+
bool hasEquivalentContent(DocumentNode other) {
64+
if (other is! TableBlockNode) {
65+
return false;
66+
}
67+
68+
if (!super.hasEquivalentContent(other)) {
69+
return false;
70+
}
71+
72+
if (rowCount != other.rowCount) {
73+
return false;
74+
}
75+
76+
if (columnCount != other.columnCount) {
77+
return false;
78+
}
79+
80+
for (int row = 0; row < rowCount; row += 1) {
81+
for (int col = 0; col < columnCount; col += 1) {
82+
final myCell = getCell(rowIndex: row, columnIndex: col);
83+
final otherCell = other.getCell(rowIndex: row, columnIndex: col);
84+
if (!myCell.hasEquivalentContent(otherCell)) {
85+
return false;
86+
}
87+
}
88+
}
89+
90+
return true;
91+
}
92+
6293
@override
6394
DocumentNode copyAndReplaceMetadata(Map<String, dynamic> newMetadata) {
6495
return TableBlockNode(

0 commit comments

Comments
 (0)