diff --git a/.github/workflows/pr_validation.yaml b/.github/workflows/pr_validation.yaml index 36c47aaa2..91098fb74 100644 --- a/.github/workflows/pr_validation.yaml +++ b/.github/workflows/pr_validation.yaml @@ -193,6 +193,46 @@ jobs: name: golden-failures path: "**/failures/**/*.png" + analyze_super_editor_clipboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_clipboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "master" + + # Download all the packages that the app uses + - run: flutter pub get + + # Enforce static analysis + - run: flutter analyze + + test_super_editor_clipboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./super_editor_clipboard + steps: + # Checkout the PR branch + - uses: actions/checkout@v3 + + # Setup Flutter environment + - uses: subosito/flutter-action@v2 + with: + channel: "master" + + # Download all the packages that the app uses + - run: flutter pub get + + # Run all tests + - run: flutter test + analyze_super_keyboard: runs-on: ubuntu-latest defaults: diff --git a/super_editor/lib/src/default_editor/multi_node_editing.dart b/super_editor/lib/src/default_editor/multi_node_editing.dart index 3eaaed4a9..86a9d3d90 100644 --- a/super_editor/lib/src/default_editor/multi_node_editing.dart +++ b/super_editor/lib/src/default_editor/multi_node_editing.dart @@ -97,8 +97,29 @@ class PasteStructuredContentEditorCommand extends EditCommand { return; } - final (upstreamNodeId, _) = _splitPasteParagraph( - executor, currentNodeWithSelection.id, (pastePosition.nodePosition as TextNodePosition).offset); + late final String upstreamNodeId; + DocumentPosition? caretPositionAfterPaste; + + if (currentNodeWithSelection.text.isEmpty || + (pastePosition.nodePosition as TextNodePosition).offset == currentNodeWithSelection.text.length) { + // We're pasting into an empty node, or pasting at the very end of a non-empty `TextNode`. + // We already know we can't combine the pasted content with this node. We'll paste below + // this node. + upstreamNodeId = currentNodeWithSelection.id; + } else { + // We're pasting into the middle of a non-empty text node. We already know we can't combine + // the pasted content with this node. Split the selected node before pasting. + final (splitUpstreamNodeId, splitDownstreamNodeId) = _splitPasteParagraph( + executor, currentNodeWithSelection.id, (pastePosition.nodePosition as TextNodePosition).offset); + upstreamNodeId = splitUpstreamNodeId; + + // Since we split a non-empty paragraph, we'll insert the caret at the start + // of the 2nd half of the split text. + caretPositionAfterPaste = DocumentPosition( + nodeId: splitDownstreamNodeId, + nodePosition: const TextNodePosition(offset: 0), + ); + } // Insert the pasted node after the split upstream node. document.insertNodeAfter( @@ -108,17 +129,53 @@ class PasteStructuredContentEditorCommand extends EditCommand { executor.logChanges([ DocumentEdit( NodeInsertedEvent(pastedNode.id, document.getNodeIndexById(pastedNode.id)), - ) + ), ]); + // Maybe delete the original selected node, and maybe insert empty paragraph at end. + if (currentNodeWithSelection.text.isEmpty) { + // We pasted content below the selected node, but the selected node was empty. + // As a UX policy, let's delete that empty paragraph because a user won't expect + // it to stay around. + document.deleteNode(currentNodeWithSelection.id); + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(pastedNode.id, currentNodeWithSelection), + ), + ]); + + if (pastedNode is! TextNode) { + // The pasted content isn't text. It might be an image, table, etc. As a UX + // policy, we insert an empty paragraph after the pasted content because users + // typically expect to be able to start typing after pasting. + final newNodeId = Editor.createNodeId(); + document.insertNodeAfter( + existingNodeId: pastedNode.id, + newNode: ParagraphNode(id: newNodeId, text: AttributedText()), + ); + executor.logChanges([ + DocumentEdit( + NodeInsertedEvent(newNodeId, document.getNodeIndexById(newNodeId)), + ), + ]); + + caretPositionAfterPaste = DocumentPosition(nodeId: newNodeId, nodePosition: const TextNodePosition(offset: 0)); + } + } + + // We didn't split a non-empty paragraph, and we didn't insert a new empty paragraph + // at the end of the pasted content. Therefore, place the caret at the end of the pasted + // content. + caretPositionAfterPaste ??= DocumentPosition( + nodeId: pastedNode.id, + nodePosition: pastedNode.endPosition, + ); + // Place the caret at the end of the pasted content. executor.executeCommand( ChangeSelectionCommand( DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: pastedNode.id, - nodePosition: pastedNode.endPosition, - ), + position: caretPositionAfterPaste, ), SelectionChangeType.insertContent, SelectionReason.userInteraction, @@ -163,14 +220,16 @@ class PasteStructuredContentEditorCommand extends EditCommand { // We've pasted the first new node. Remove it from the nodes to insert. nodesToInsert.removeAt(0); - } - if (currentNodeWithSelection.text.length == 0) { + } else if (currentNodeWithSelection.text.length == 0) { // The node with the selection is an empty text node. After we use that node's // position to insert other nodes, we want to delete that first node, as if the // pasted content replaced it. deleteInitiallySelectedNode = true; } + // The caret position we want after the paste. + DocumentPosition? pasteEndPosition; + // (Possibly) merge or delete the downstream split node. if (nodesToInsert.isNotEmpty) { final lastPastedNode = nodesToInsert.last; @@ -193,6 +252,13 @@ class PasteStructuredContentEditorCommand extends EditCommand { // We've pasted the last new node. Remove it from the nodes to insert. nodesToInsert.removeLast(); + + // Since we combined the last paste node with the 2nd half of the original + // node, the caret position sits in the middle of that combined node. + pasteEndPosition = DocumentPosition( + nodeId: downstreamSplitNode.id, + nodePosition: TextNodePosition(offset: lastPastedNode.text.length), + ); } } @@ -212,6 +278,10 @@ class PasteStructuredContentEditorCommand extends EditCommand { ) ]); } + pasteEndPosition ??= DocumentPosition( + nodeId: previousNode.id, + nodePosition: previousNode.endPosition, + ); if (deleteInitiallySelectedNode) { document.deleteNode(currentNodeWithSelection.id); @@ -225,12 +295,7 @@ class PasteStructuredContentEditorCommand extends EditCommand { // Place the caret at the end of the pasted content. executor.executeCommand( ChangeSelectionCommand( - DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: previousNode.id, - nodePosition: previousNode.endPosition, - ), - ), + DocumentSelection.collapsed(position: pasteEndPosition), SelectionChangeType.insertContent, SelectionReason.userInteraction, ), diff --git a/super_editor/lib/src/default_editor/tables/table_block.dart b/super_editor/lib/src/default_editor/tables/table_block.dart index d07a7762b..8afd46b69 100644 --- a/super_editor/lib/src/default_editor/tables/table_block.dart +++ b/super_editor/lib/src/default_editor/tables/table_block.dart @@ -59,6 +59,37 @@ class TableBlockNode extends BlockNode { return row[columnIndex]; } + @override + bool hasEquivalentContent(DocumentNode other) { + if (other is! TableBlockNode) { + return false; + } + + if (!super.hasEquivalentContent(other)) { + return false; + } + + if (rowCount != other.rowCount) { + return false; + } + + if (columnCount != other.columnCount) { + return false; + } + + for (int row = 0; row < rowCount; row += 1) { + for (int col = 0; col < columnCount; col += 1) { + final myCell = getCell(rowIndex: row, columnIndex: col); + final otherCell = other.getCell(rowIndex: row, columnIndex: col); + if (!myCell.hasEquivalentContent(otherCell)) { + return false; + } + } + } + + return true; + } + @override DocumentNode copyAndReplaceMetadata(Map newMetadata) { return TableBlockNode( diff --git a/super_editor/test/infrastructure/paste_test.dart b/super_editor/test/infrastructure/paste_test.dart new file mode 100644 index 000000000..0d5ed53b4 --- /dev/null +++ b/super_editor/test/infrastructure/paste_test.dart @@ -0,0 +1,359 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/test/super_editor_test/supereditor_test_tools.dart'; +import 'package:super_editor/super_editor.dart'; + +void main() { + group("Paste >", () { + group("multi-node content >", () { + test("paste single paragraph into empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("Hello, World!")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Hello, World!")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 13)), + ), + ); + }); + + test("paste single paragraph into middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefghi"))], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("Hello, World!")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 4)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdHello, World!efghi")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 17)), + ), + ); + }); + + test("paste table in empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste a table. + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument(nodes: [ + _table, + ]), + pastePosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + ]); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + _table, + ParagraphNode(id: editor.document.last.id, text: AttributedText()), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("paste table in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + const ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste a table. + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument(nodes: [ + _table, + ]), + pastePosition: const DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + ]); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcd")), + _table, + ParagraphNode(id: editor.document.last.id, text: AttributedText("efgh")), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("paste multiple nodes into empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 0)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + ), + ); + expect( + editor.composer.selection, + const DocumentSelection.collapsed( + position: DocumentPosition(nodeId: "4", nodePosition: TextNodePosition(offset: 5)), + ), + ); + }); + + test("paste multiple nodes into middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefghi"))], + ), + ); + + editor.execute([ + PasteStructuredContentEditorRequest( + content: MutableDocument( + nodes: [ + ParagraphNode(id: "2", text: AttributedText("One")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Three")), + ], + ), + pastePosition: const DocumentPosition(nodeId: "1", nodePosition: TextNodePosition(offset: 4)), + ), + ]); + + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdOne")), + ParagraphNode(id: "3", text: AttributedText("Two")), + ParagraphNode(id: "4", text: AttributedText("Threeefghi")), + ], + ), + ), + ); + + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: + DocumentPosition(nodeId: editor.document.last.id, nodePosition: const TextNodePosition(offset: 5)), + ), + ); + }); + }); + }); +} + +final _table = TableBlockNode(id: "table", cells: [ + [ + TextNode( + id: "1.1", + text: AttributedText("BMI Category"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + }, + ), + TextNode( + id: "1.2", + text: AttributedText("BMI Range (kg/m²)"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + }, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Underweight"), + ), + TextNode( + id: "2.2", + text: AttributedText("< 18.5"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Normal weight"), + ), + TextNode( + id: "3.2", + text: AttributedText("18.5 – 24.9"), + ), + ], + [ + TextNode( + id: "4.1", + text: AttributedText("Overweight"), + ), + TextNode( + id: "4.2", + text: AttributedText("25.0 - 29.9"), + ), + ], + [ + TextNode( + id: "5.1", + text: AttributedText("Obesity (Class I)"), + ), + TextNode( + id: "5.2", + text: AttributedText("30.0 - 34.9"), + ), + ], + [ + TextNode( + id: "6.1", + text: AttributedText("Obesity (Class II)"), + ), + TextNode( + id: "6.2", + text: AttributedText("35.0 - 39.9"), + ), + ], + [ + TextNode( + id: "7.1", + text: AttributedText("Obesity (Class III)"), + ), + TextNode( + id: "7.2", + text: AttributedText("≥ 40.0"), + ), + ], +]); diff --git a/super_editor_clipboard/lib/src/editor_paste.dart b/super_editor_clipboard/lib/src/editor_paste.dart new file mode 100644 index 000000000..ebb9ef9ae --- /dev/null +++ b/super_editor_clipboard/lib/src/editor_paste.dart @@ -0,0 +1,42 @@ +import 'package:html2md/html2md.dart' as html2md; +import 'package:super_editor/super_editor.dart'; + +extension RichTextPaste on Editor { + void pasteHtml(Editor editor, String html) { + final markdown = html2md.convert(html); + final contentToPaste = deserializeMarkdownToDocument(markdown); + + final composer = editor.composer; + DocumentPosition? pastePosition = composer.selection!.extent; + + // Delete all currently selected content. + if (!composer.selection!.isCollapsed) { + pastePosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( + document: editor.document, + selection: composer.selection!, + ); + + if (pastePosition == null) { + // There are no deletable nodes in the selection. Do nothing. + return; + } + + // Delete the selected content. + editor.execute([ + DeleteContentRequest(documentRange: composer.selection!), + ChangeSelectionRequest( + DocumentSelection.collapsed(position: pastePosition), + SelectionChangeType.deleteContent, + SelectionReason.userInteraction, + ), + ]); + } + + editor.execute([ + PasteStructuredContentEditorRequest( + content: contentToPaste, + pastePosition: pastePosition, + ), + ]); + } +} diff --git a/super_editor_clipboard/lib/src/super_editor_paste.dart b/super_editor_clipboard/lib/src/super_editor_paste.dart new file mode 100644 index 000000000..324d5bc93 --- /dev/null +++ b/super_editor_clipboard/lib/src/super_editor_paste.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor_clipboard/src/editor_paste.dart'; + +/// Pastes rich text from the system clipboard when the user presses CMD+V on +/// Mac, or CTRL+V on Windows/Linux. +/// +/// This method expects to find rich text on the system clipboard as HTML, which +/// is then converted to Markdown, and then converted to a [Document]. +ExecutionInstruction pasteRichTextOnCmdCtrlV({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent) { + return ExecutionInstruction.continueExecution; + } + + if (!HardwareKeyboard.instance.isMetaPressed && !HardwareKeyboard.instance.isControlPressed) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyV) { + return ExecutionInstruction.continueExecution; + } + + // Cmd/Ctrl+V detected - handle clipboard paste + _pasteFromClipboard(editContext.editor); + + return ExecutionInstruction.haltExecution; +} + +Future _pasteFromClipboard(Editor editor) async { + final clipboard = SystemClipboard.instance; + if (clipboard == null) { + return; + } + + final reader = await clipboard.read(); + + // Try to paste rich text (via HTML). + var didPaste = await _maybePasteHtml(editor, reader); + if (didPaste) { + return; + } + + // Fall back to plain text. + _pastePlainText(editor, reader); +} + +Future _maybePasteHtml(Editor editor, ClipboardReader reader) async { + final completer = Completer(); + + reader.getValue( + Formats.htmlText, + (html) { + if (html == null) { + completer.complete(false); + return; + } + + // Do the paste. + editor.pasteHtml(editor, html); + + completer.complete(true); + }, + onError: (_) { + completer.complete(false); + }, + ); + + final didPaste = await completer.future; + return didPaste; +} + +void _pastePlainText(Editor editor, ClipboardReader reader) { + reader.getValue(Formats.plainText, (value) { + if (value != null) { + editor.execute([InsertPlainTextAtCaretRequest(value)]); + } + }); +} diff --git a/super_editor_clipboard/lib/src/super_reader_copy.dart b/super_editor_clipboard/lib/src/super_reader_copy.dart index caa03e933..61c631bfb 100644 --- a/super_editor_clipboard/lib/src/super_reader_copy.dart +++ b/super_editor_clipboard/lib/src/super_reader_copy.dart @@ -4,6 +4,7 @@ import 'package:super_editor_clipboard/src/document_copy.dart'; /// [SuperReader] shortcut to copy the selected content within the document /// as rich text, on Mac. +// ignore: deprecated_member_use final copyAsRichTextWhenCmdCIsPressedOnMac = createShortcut( ({required SuperReaderContext documentContext, required KeyEvent keyEvent}) { if (documentContext.editor.composer.selection == null) { @@ -27,6 +28,7 @@ final copyAsRichTextWhenCmdCIsPressedOnMac = createShortcut( /// [SuperReader] shortcut to copy the selected content within the document /// as rich text, on Windows and Linux. +// ignore: deprecated_member_use final copyAsRichTextWhenCtrlCIsPressedOnWindowsAndLinux = createShortcut( ({required SuperReaderContext documentContext, required KeyEvent keyEvent}) { if (documentContext.editor.composer.selection == null) { diff --git a/super_editor_clipboard/lib/super_editor_clipboard.dart b/super_editor_clipboard/lib/super_editor_clipboard.dart index b4cb78524..edf36bba3 100644 --- a/super_editor_clipboard/lib/super_editor_clipboard.dart +++ b/super_editor_clipboard/lib/super_editor_clipboard.dart @@ -1,4 +1,6 @@ export 'src/document_copy.dart'; +export 'src/editor_paste.dart'; export 'src/super_editor_copy.dart'; +export 'src/super_editor_paste.dart'; export 'src/super_message_copy.dart'; export 'src/super_reader_copy.dart'; diff --git a/super_editor_clipboard/pubspec.yaml b/super_editor_clipboard/pubspec.yaml index 1e1fe0f1d..f714137c5 100644 --- a/super_editor_clipboard/pubspec.yaml +++ b/super_editor_clipboard/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: collection: ^1.19.1 flutter: sdk: flutter + html2md: ^1.3.2 super_clipboard: ^0.9.1 super_editor: ^0.3.0-dev.44 diff --git a/super_editor_clipboard/test/copy_and_paste_test.dart b/super_editor_clipboard/test/copy_and_paste_test.dart new file mode 100644 index 000000000..d032d7c28 --- /dev/null +++ b/super_editor_clipboard/test/copy_and_paste_test.dart @@ -0,0 +1,489 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; +import 'package:super_editor_clipboard/super_editor_clipboard.dart'; + +void main() { + group("Max > Super Editor > copy and paste >", () { + group("HTML >", () { + test("pastes plain text in empty paragraph", () { + const html = "

Hello, World!

"; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("Hello, World!")), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 13), + ), + ), + ); + }); + + test("pastes plain text in middle of paragraph", () { + const html = "

Hello, World!

"; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode( + id: "1", + text: AttributedText("abcdHello, World!efgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 17), + ), + ), + ); + }); + + test("pastes multiple paragraphs in empty paragraph", () { + const html = "

One

Two

Three

"; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect(editor.document.length, 3); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("One")), + ParagraphNode( + id: editor.document.getNodeAt(1)!.id, + text: AttributedText("Two"), + metadata: {'textAlign': null}, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Three"), + metadata: {'textAlign': null}, + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.getNodeAt(2)!.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + + test("pastes multiple paragraphs in middle of paragraph", () { + const html = "

One

Two

Three

"; + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, html); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect(editor.document.length, 3); + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdOne")), + ParagraphNode( + id: editor.document.getNodeAt(1)!.id, + text: AttributedText("Two"), + metadata: {'textAlign': null}, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Threeefgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.getNodeAt(2)!.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + + test("pastes table in empty paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText())], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, '$_tableHtml'); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + TableBlockNode( + id: editor.document.first.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.last.id, + text: AttributedText(), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("pastes table in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, '$_tableHtml'); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcd")), + TableBlockNode( + id: editor.document.getNodeAt(1)!.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.last.id, + text: AttributedText("efgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 0), + ), + ), + ); + }); + + test("pastes mixed content in middle of paragraph", () { + final editor = createDefaultDocumentEditor( + document: MutableDocument( + nodes: [ParagraphNode(id: "1", text: AttributedText("abcdefgh"))], + ), + ); + + // Place the caret so we know where to paste. + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 4), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), + ]); + + // Paste the HTML. + editor.pasteHtml(editor, _mixedContent); + + // Ensure the HTML was turned into the expected document, with the + // expected selection. + expect( + editor.document, + documentEquivalentTo( + MutableDocument( + nodes: [ + ParagraphNode(id: "1", text: AttributedText("abcdBefore")), + TableBlockNode( + id: editor.document.getNodeAt(1)!.id, + cells: _tableCells, + ), + ParagraphNode( + id: editor.document.getNodeAt(2)!.id, + text: AttributedText("Afterefgh"), + ), + ], + ), + ), + ); + expect( + editor.composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: editor.document.last.id, + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + }); + }); + }); +} + +const _mixedContent = '' + '

Before

$_tableHtml

After

'; + +const _tableHtml = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
BMI CategoryBMI Range (kg/m²)
Underweight< 18.5
Normal weight18.5 - 24.9
Overweight25.0 - 29.9
Obesity (Class I)30.0 - 34.9
Obesity (Class II)35.0 - 39.9
Obesity (Class III)≥ 40.0
'; + +final _tableCells = [ + [ + TextNode( + id: "1.1", + text: AttributedText("BMI Category"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + 'textAlign': TextAlign.center, + }, + ), + TextNode( + id: "1.2", + text: AttributedText("BMI Range (kg/m²)"), + metadata: const { + NodeMetadata.blockType: tableHeaderAttribution, + 'textAlign': TextAlign.center, + }, + ), + ], + [ + TextNode( + id: "2.1", + text: AttributedText("Underweight"), + ), + TextNode( + id: "2.2", + text: AttributedText("< 18.5"), + ), + ], + [ + TextNode( + id: "3.1", + text: AttributedText("Normal weight"), + ), + TextNode( + id: "3.2", + text: AttributedText("18.5 - 24.9"), + ), + ], + [ + TextNode( + id: "4.1", + text: AttributedText("Overweight"), + ), + TextNode( + id: "4.2", + text: AttributedText("25.0 - 29.9"), + ), + ], + [ + TextNode( + id: "5.1", + text: AttributedText("Obesity (Class I)"), + ), + TextNode( + id: "5.2", + text: AttributedText("30.0 - 34.9"), + ), + ], + [ + TextNode( + id: "6.1", + text: AttributedText("Obesity (Class II)"), + ), + TextNode( + id: "6.2", + text: AttributedText("35.0 - 39.9"), + ), + ], + [ + TextNode( + id: "7.1", + text: AttributedText("Obesity (Class III)"), + ), + TextNode( + id: "7.2", + text: AttributedText("≥ 40.0"), + ), + ], +];