diff --git a/super_editor/lib/src/core/editor.dart b/super_editor/lib/src/core/editor.dart index bc583c2fd..c5252df39 100644 --- a/super_editor/lib/src/core/editor.dart +++ b/super_editor/lib/src/core/editor.dart @@ -1246,6 +1246,12 @@ class MutableDocument with Iterable implements Document, Editable return isRemoved; } + /// Deletes all nodes from the [Document]. + void clear() { + _nodes.clear(); + _refreshNodeIdCaches(); + } + /// Moves a [DocumentNode] matching the given [nodeId] from its current index /// in the [Document] to the given [targetIndex]. /// diff --git a/super_editor/lib/src/default_editor/default_document_editor.dart b/super_editor/lib/src/default_editor/default_document_editor.dart index 249021ae1..7f3139998 100644 --- a/super_editor/lib/src/default_editor/default_document_editor.dart +++ b/super_editor/lib/src/default_editor/default_document_editor.dart @@ -120,6 +120,9 @@ final defaultRequestHandlers = List.unmodifiable([ (request) => request is DeleteNodeRequest // ? DeleteNodeCommand(nodeId: request.nodeId) : null, + (request) => request is ClearDocumentRequest // + ? ClearDocumentCommand() + : null, (request) => request is DeleteUpstreamCharacterRequest // ? const DeleteUpstreamCharacterCommand() : null, 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 5abda1cf7..2022d8532 100644 --- a/super_editor/lib/src/default_editor/multi_node_editing.dart +++ b/super_editor/lib/src/default_editor/multi_node_editing.dart @@ -1312,3 +1312,57 @@ class DeleteNodeCommand extends EditCommand { ]); } } + +/// An [EditRequest] to clear the document's content. +/// +/// This request: +/// +/// - Removes all nodes from the document. +/// - Adds a new empty paragraph. +/// - Places the caret at the beginning of the new paragraph. +/// - Clears the composing region. +class ClearDocumentRequest implements EditRequest { + const ClearDocumentRequest(); +} + +class ClearDocumentCommand extends EditCommand { + @override + void execute(EditContext context, CommandExecutor executor) { + final document = context.document; + + for (final node in document) { + executor.logChanges([ + DocumentEdit( + NodeRemovedEvent(node.id, node), + ) + ]); + } + + document.clear(); + + final newNodeId = Editor.createNodeId(); + executor + ..executeCommand( + InsertNodeAtIndexCommand( + nodeIndex: 0, + newNode: ParagraphNode( + id: newNodeId, + text: AttributedText(), + ), + ), + ) + ..executeCommand( + ChangeSelectionCommand( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: newNodeId, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + SelectionChangeType.insertContent, + SelectionReason.userInteraction, + ), + ) + ..executeCommand(ChangeComposingRegionCommand(null)); + } +} diff --git a/super_editor/test/super_editor/supereditor_content_deletion_test.dart b/super_editor/test/super_editor/supereditor_content_deletion_test.dart new file mode 100644 index 000000000..db3fb8cd4 --- /dev/null +++ b/super_editor/test/super_editor/supereditor_content_deletion_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_runners/flutter_test_runners.dart'; +import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/super_editor_test.dart'; + +import 'supereditor_test_tools.dart'; + +void main() { + group('SuperEditor > content deletion >', () { + testWidgetsOnAllPlatforms('clears document', (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .pump(); + + // Place the caret at an arbitraty node. We don't place the caret at the + // beginning of the document to make sure the selection will move + // to the beginning of the document after the deletion. + await tester.placeCaretInParagraph('2', 0); + + // Hold the state sent to the platform. + String? text; + int? selectionBase; + int? selectionExtent; + String? selectionAffinity; + int? composingBase; + int? composingExtent; + + // Intercept the setEditingState message sent to the platform. + tester + .interceptChannel(SystemChannels.textInput.name) // + .interceptMethod( + 'TextInput.setEditingState', + (methodCall) { + if (methodCall.method == 'TextInput.setEditingState') { + text = methodCall.arguments['text']; + selectionBase = methodCall.arguments['selectionBase']; + selectionExtent = methodCall.arguments['selectionExtent']; + selectionAffinity = methodCall.arguments['selectionAffinity']; + composingBase = methodCall.arguments["composingBase"]; + composingExtent = methodCall.arguments["composingExtent"]; + } + return null; + }, + ); + + // Delete all content. + testContext.editor.execute([const ClearDocumentRequest()]); + await tester.pump(); + + // Ensure the document was cleared and a new empty paragraph was added. + final document = testContext.document; + expect(document.length, equals(1)); + expect(document.first, isA()); + expect((document.first as ParagraphNode).text.text, equals('')); + + // Ensure the selection was moved to the end of the document. + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: document.first.id, + nodePosition: const TextNodePosition(offset: 0), + ), + ), + ); + + // Ensure the composing region was cleared. + expect(testContext.composer.composingRegion.value, isNull); + + // Ensure the state was correctly sent to the platform. + expect(text, equals('. ')); + expect(selectionBase, equals(2)); + expect(selectionExtent, equals(2)); + expect(selectionAffinity, equals('TextAffinity.downstream')); + expect(composingBase, equals(-1)); + expect(composingExtent, equals(-1)); + + // Ensure the user can still type text. + await tester.typeImeText('Hello world!'); + expect((document.first as ParagraphNode).text.text, equals('Hello world!')); + }); + }); +}