Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 65 additions & 19 deletions super_editor/lib/src/default_editor/common_editor_operations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ class CommonEditorOperations {
}) {
DocumentPosition? position;
if (findNearestPosition) {
position = documentLayoutResolver().getDocumentPositionNearestToOffset(documentOffset);
position = documentLayoutResolver()
.getDocumentPositionNearestToOffset(documentOffset);
} else {
position = documentLayoutResolver().getDocumentPositionAtOffset(documentOffset);
}
Expand Down Expand Up @@ -2464,19 +2465,22 @@ class PasteEditorCommand extends EditCommand {

final textNode = document.getNode(_pastePosition) as TextNode;
final pasteTextOffset = (_pastePosition.nodePosition as TextPosition).offset;

if (parsedContent.length > 1 && pasteTextOffset < textNode.endPosition.offset) {
// There is more than 1 node of content being pasted. Therefore,
// new nodes will need to be added, which means that the currently
// selected text node will be split at the current text offset.
// Configure a new node to be added at the end of the pasted content
// which contains the trailing text from the currently selected
// node.
String? newNodeId;
bool doInsertInsideParagraph = pasteTextOffset < textNode.endPosition.offset;

if (parsedContent.length > 1 && doInsertInsideParagraph) {
// There is more than 1 node of content being pasted, and inside a
// paragraph, not at the end. Therefore, new nodes will need to be
// added, which means that the currently selected text node will be
// split at the current text offset and a new node has to be added
// at the end of the pasted content which contains the trailing text
// from the currently selected text node.
newNodeId = Editor.createNodeId();
executor.executeCommand(
SplitParagraphCommand(
nodeId: currentNodeWithSelection.id,
splitPosition: TextPosition(offset: pasteTextOffset),
newNodeId: Editor.createNodeId(),
newNodeId: newNodeId,
replicateExistingMetadata: true,
),
);
Expand All @@ -2493,10 +2497,37 @@ class PasteEditorCommand extends EditCommand {
}

// The first line of pasted text was added to the selected paragraph.
// Now, add all remaining pasted nodes to the document..
// Now, add all remaining pasted nodes to the document.
DocumentNode previousNode = document.getNodeById(_pastePosition.nodeId)!;
// ^ re-query the node where the first paragraph was pasted because nodes are immutable.
for (final pastedNode in parsedContent.sublist(1)) {
if (pastedNode == parsedContent.last && pastedNode is TextNode) {
// This is the last node being pasted, and it's a TextNode.
// If we had to split the existing node to insert several nodes,
// we need to merge this last pasted node with the new node that
// resulted from splitting the text node:
if (doInsertInsideParagraph) {
// this is guaranteed to be a TextNode because it was created from SplitParagraphCommand.
executor.executeCommand(
InsertAttributedTextCommand(
documentPosition: DocumentPosition(
nodeId: newNodeId ?? previousNode.id, nodePosition: const TextNodePosition(offset: 0)),
textToInsert: (parsedContent.last as TextNode).text,
),
);

executor.logChanges([
DocumentEdit(
NodeChangeEvent(newNodeId ?? previousNode.id),
)
]);
previousNode = pastedNode;
break;
}
}
// This is either not the last node, or it's not a TextNode, or it's the
// last node but we didn't split the node that we pasted in; in all cases,
// just insert it as a new node.
document.insertNodeAfter(
existingNodeId: previousNode.id,
newNode: pastedNode,
Expand All @@ -2510,17 +2541,32 @@ class PasteEditorCommand extends EditCommand {
]);
}

// Place the caret at the end of the pasted content.
final pastedNode = document.getNodeById(previousNode.id)!;
// ^ re-query the node where we pasted content because nodes are immutable.

late DocumentPosition selectionAfterPaste;
if (doInsertInsideParagraph && parsedContent.last is TextNode) {
// Content was pasted in the middle of a text node, and it ends in text, so
// the caret is placed accordingly at the end of the pasted content at
// the split position.
selectionAfterPaste = DocumentPosition(
nodeId: newNodeId ?? previousNode.id,
nodePosition: TextNodePosition(
offset: (newNodeId == null ? pasteTextOffset : 0) + (_parsedContent!.last as TextNode).text.length,
),
);
} else {
// Either insertion took place at the end of a paragraph, or the last node is
// non-text and therefore is inserted as a node of its own. In both cases,
// place the caret at the end of the pasted content.
final pastedNode = document.getNodeById(previousNode.id)!;
// ^ re-query the node where we pasted content because nodes are immutable.
selectionAfterPaste = DocumentPosition(
nodeId: pastedNode.id,
nodePosition: pastedNode.endPosition,
);
}
executor.executeCommand(
ChangeSelectionCommand(
DocumentSelection.collapsed(
position: DocumentPosition(
nodeId: pastedNode.id,
nodePosition: pastedNode.endPosition,
),
position: selectionAfterPaste,
),
SelectionChangeType.insertContent,
SelectionReason.userInteraction,
Expand Down
107 changes: 107 additions & 0 deletions super_editor/test/super_editor/supereditor_copy_and_paste_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:super_editor/super_editor.dart';
import 'package:super_editor/super_editor_test.dart';

import '../test_runners.dart';
import '../test_tools.dart';
import 'supereditor_test_tools.dart';

void main() {
Expand Down Expand Up @@ -36,6 +37,112 @@ void main() {
expect(SuperEditorInspector.findTextInComponent(nodeId).toPlainText(), "Pasted text: This was pasted here");
});

testAllInputsOnDesktop(
Comment thread
JostSchenck marked this conversation as resolved.
'pastes some text without newlines in the middle of a paragraph, placing the caret at the end of the pasted text',
(
tester, {
required TextInputSource inputSource,
}) async {
final testContext = await tester //
.createDocument()
.withSingleEmptyParagraph()
.withInputSource(inputSource)
.pump();

// Place the caret at the empty paragraph.
await tester.placeCaretInParagraph('1', 0);

// Type some text.
switch (inputSource) {
case TextInputSource.keyboard:
await tester.typeKeyboardText('This is a paragraph.');
case TextInputSource.ime:
await tester.typeImeText('This is a paragraph.');
}

// Place the cursor somewhere in the middle of the text.
await tester.placeCaretInParagraph('1', 8);

// Simulate pasting multiple lines.
tester
..simulateClipboard()
..setSimulatedClipboardContent('some content in ');
if (defaultTargetPlatform == TargetPlatform.macOS) {
await tester.pressCmdV();
} else {
await tester.pressCtlV();
}

// Ensure the text is correctly pasted and the caret is placed at the
// end of the pasted text.
final doc = testContext.document;
expect(doc.nodeCount, 1);
expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'This is some content in a paragraph.');
expect(
testContext.composer.selection,
selectionEquivalentTo(const DocumentSelection.collapsed(
position: DocumentPosition(
nodeId: '1',
nodePosition: TextNodePosition(offset: 24),
),
)));
});

testAllInputsOnDesktop(
'pastes some text with newlines in the middle of a paragraph, correctly splitting it, inserting nodes and placing the caret at the end of the pasted text',
(
tester, {
required TextInputSource inputSource,
}) async {
final testContext = await tester //
.createDocument()
.withSingleEmptyParagraph()
.withInputSource(inputSource)
.pump();

// Place the caret at the empty paragraph.
await tester.placeCaretInParagraph('1', 0);

// Type some text.
switch (inputSource) {
case TextInputSource.keyboard:
await tester.typeKeyboardText('This is a paragraph.');
case TextInputSource.ime:
await tester.typeImeText('This is a paragraph.');
}

// Place the cursor somewhere in the middle of the text.
await tester.placeCaretInParagraph('1', 8);

// Simulate pasting multiple lines.
tester
..simulateClipboard()
..setSimulatedClipboardContent('''some content in a paragraph.
There is a second paragraph here.
And a third one is also ''');
if (defaultTargetPlatform == TargetPlatform.macOS) {
await tester.pressCmdV();
} else {
await tester.pressCtlV();
}

// Ensure the text is correctly pasted and the caret is placed at the
// end of the pasted text.
final doc = testContext.document;
expect(doc.nodeCount, 3);
expect((doc.getNodeAt(0)! as ParagraphNode).text.toPlainText(), 'This is some content in a paragraph.');
expect((doc.getNodeAt(1)! as ParagraphNode).text.toPlainText(), 'There is a second paragraph here.');
expect((doc.getNodeAt(2)! as ParagraphNode).text.toPlainText(), 'And a third one is also a paragraph.');
expect(
testContext.composer.selection,
selectionEquivalentTo(DocumentSelection.collapsed(
position: DocumentPosition(
nodeId: testContext.document.last.id,
nodePosition: const TextNodePosition(offset: 24),
),
)));
});

testWidgetsOnApple('pastes within a list item', (tester) async {
await tester //
.createDocument()
Expand Down
Loading