diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_value.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_value.dart index 0e0d70472..b5fcba2bb 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_value.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_value.dart @@ -4,6 +4,15 @@ import 'package:flutter/services.dart'; /// The logical value of a text editable that displays attributed /// text. class AttributedTextEditingValue { + factory AttributedTextEditingValue.empty() => AttributedTextEditingValue( + text: AttributedText(text: ""), + ); + + factory AttributedTextEditingValue.emptyWithCaret() => AttributedTextEditingValue( + text: AttributedText(text: ""), + selection: const TextSelection.collapsed(offset: 0), + ); + const AttributedTextEditingValue({ required this.text, this.selection = const TextSelection.collapsed(offset: -1), diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart index 1e9662ccf..74de3a75d 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/commands.dart @@ -1,17 +1,90 @@ import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/painting.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/attributed_text_editing_value.dart'; import 'event_source_value.dart'; +/// Selects all text in the editable. +class SelectAllCommand extends AttributedTextEditingValueCommand { + TextSelection? _previousSelection; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _previousSelection = previousValue.selection; + + return AttributedTextEditingValue( + text: previousValue.text.copy(), + selection: TextSelection( + baseOffset: 0, + extentOffset: previousValue.text.text.length, + ), + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: currentValue.text, + selection: _previousSelection!, + ); + } +} + +/// Toggles the given attributions on/off within the selected text region. +class ToggleSelectionAttributionsCommand extends AttributedTextEditingValueCommand { + ToggleSelectionAttributionsCommand(this.toggleAttributions); + + /// The attributions that will be turned on/off within the current + /// text selection. + final Set toggleAttributions; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + final newText = previousValue.text.copy(); + final selectionRange = previousValue.selection.toSpanRange(); + for (final attribution in toggleAttributions) { + newText.toggleAttribution(attribution, selectionRange); + } + + return AttributedTextEditingValue( + text: newText, + selection: previousValue.selection, + composingRegion: previousValue.composingRegion, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + final revertedText = currentValue.text.copy(); + final selectionRange = currentValue.selection.toSpanRange(); + for (final attribution in toggleAttributions) { + revertedText.toggleAttribution(attribution, selectionRange); + } + + return AttributedTextEditingValue( + text: revertedText, + selection: currentValue.selection, + composingRegion: currentValue.composingRegion, + ); + } +} + /// Inserts the given text at the current caret location. /// /// The [AttributedTextEditingValue] must have a collapsed selection. -class TextFieldInsertTextCommand extends AttributedTextEditingValueCommand { - TextFieldInsertTextCommand(this.textToInsert); +class InsertTextAtCaretCommand extends AttributedTextEditingValueCommand { + InsertTextAtCaretCommand( + this.textToInsert, { + this.composingRegion, + }); + /// The text to insert into the [AttributedTextEditingValue]. final AttributedText textToInsert; + /// The new composing region after the text is inserted. + final TextRange? composingRegion; + TextRange? _previousComposingRegion; @override @@ -29,6 +102,7 @@ class TextFieldInsertTextCommand extends AttributedTextEditingValueCommand { selection: TextSelection.collapsed( offset: previousValue.selection.extentOffset + textToInsert.text.length, ), + composingRegion: composingRegion ?? TextRange.empty, ); return newValue; @@ -53,3 +127,110 @@ class TextFieldInsertTextCommand extends AttributedTextEditingValueCommand { return newValue; } } + +/// Inserts the given text at a desired text offset. +class InsertTextAtOffsetCommand extends AttributedTextEditingValueCommand { + InsertTextAtOffsetCommand({ + required this.textToInsert, + required this.insertionOffset, + required this.selectionAfter, + this.composingRegion, + }); + + /// The text to insert into the [AttributedTextEditingValue]. + final AttributedText textToInsert; + + /// The text offset where the new text should be inserted. + final int insertionOffset; + + /// The selection that should be present after inserting the new text. + final TextSelection selectionAfter; + + /// The new composing region after the text is inserted. + final TextRange? composingRegion; + + TextSelection? _previousSelection; + TextRange? _previousComposingRegion; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _previousSelection = previousValue.selection; + _previousComposingRegion = previousValue.composingRegion; + + final newValue = AttributedTextEditingValue( + text: previousValue.text.insert( + textToInsert: textToInsert, + startOffset: insertionOffset, + ), + selection: selectionAfter, + composingRegion: composingRegion ?? TextRange.empty, + ); + + return newValue; + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + final newValue = AttributedTextEditingValue( + text: currentValue.text.removeRegion( + startOffset: insertionOffset - textToInsert.text.length, + endOffset: insertionOffset, + ), + selection: _previousSelection!, + composingRegion: _previousComposingRegion!, + ); + + _previousSelection = null; + _previousComposingRegion = null; + + return newValue; + } +} + +extension on AttributedText { + AttributedText copy() { + return AttributedText( + text: text, + spans: spans.copy(), + ); + } +} + +/// Replaces the text, selection, and composing region in the editable. +class ReplaceContentCommands extends AttributedTextEditingValueCommand { + ReplaceContentCommands({ + required this.newText, + required this.newSelection, + this.newComposingRegion = TextRange.empty, + }); + + final AttributedText newText; + final TextSelection newSelection; + final TextRange newComposingRegion; + + AttributedTextEditingValue? _previousValue; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _previousValue = AttributedTextEditingValue( + text: previousValue.text.copy(), + selection: previousValue.selection, + composingRegion: previousValue.composingRegion, + ); + + return AttributedTextEditingValue( + text: newText, + selection: newSelection, + composingRegion: newComposingRegion, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: _previousValue!.text.copy(), + selection: _previousValue!.selection, + composingRegion: _previousValue!.composingRegion, + ); + } +} diff --git a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart index 83b5be895..be5601dea 100644 --- a/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart +++ b/super_editor/lib/src/infrastructure/super_textfield/infrastructure/editing/event_source_controller.dart @@ -11,11 +11,23 @@ import 'package:super_editor/src/infrastructure/super_textfield/infrastructure/a import 'package:super_text_layout/super_text_layout.dart'; import '../attributed_text_editing_value.dart'; +import 'commands.dart'; +import 'event_source_value.dart'; class EventSourcedAttributedTextEditingController with ChangeNotifier implements AttributedTextEditingController { - EventSourcedAttributedTextEditingController(this._value); + EventSourcedAttributedTextEditingController( + AttributedTextEditingValue initialValue, + ) : _value = EventSourcedAttributedTextEditingValue(initialValue); - AttributedTextEditingValue _value; + final EventSourcedAttributedTextEditingValue _value; + + bool get isUndoable => _value.isUndoable; + + bool undo() => _value.undo(); + + bool get isRedoable => _value.isRedoable; + + bool redo() => _value.redo(); @override AttributedText get text => _value.text; @@ -142,11 +154,6 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } - @override - void selectAll() { - // TODO: create a command - } - /// Toggles the presence of each of the given [attributions] within /// the text in the [selection]. @override @@ -154,12 +161,13 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements if (attributions.isEmpty) { return; } - if (selection.isCollapsed) { return; } - // TODO: create a command + _value.execute( + ToggleSelectionAttributionsCommand(attributions.toSet()), + ); } /// Removes all attributions from the text that is currently selected. @@ -172,99 +180,115 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } + @override + void selectAll() { + _value.execute(SelectAllCommand()); + } + @override void updateTextAndSelection({ required AttributedText text, required TextSelection selection, + TextRange composingRegion = TextRange.empty, }) { - // TODO: create a command + _value.execute( + ReplaceContentCommands( + newText: text, + newSelection: selection, + newComposingRegion: composingRegion, + ), + ); } @override void insertCharacter(String character) { - // TODO: create a command + insertAtCaretWithUpstreamAttributions(text: character); } @override void insertNewline() { - // TODO: create a command + insertAtCaretWithUpstreamAttributions(text: "\n"); } /// Inserts the given [text] at the current caret position. /// /// The current [composingAttributions] are applied to the given /// [text] and the caret is moved to the end of the given [text]. - /// - /// If the current [selection] is expanded, this method does nothing, - /// because there is no conceptual caret with an expanded selection. @override void insertAtCaret({ required String text, TextRange? newComposingRegion, }) { - if (!selection.isCollapsed) { - textFieldLog.warning('Attempted to insert text at the caret with an expanded selection. Selection: $selection'); - return; + _ensureCaretReadyForInsertion(); + + final attributedText = AttributedText(text: text); + final attributions = Set.from(composingAttributions); + for (final attribution in attributions) { + attributedText.addAttribution( + attribution, + SpanRange(start: 0, end: attributedText.text.length - 1), + ); } - // TODO: create a command + _value.execute(InsertTextAtCaretCommand( + attributedText, + composingRegion: newComposingRegion ?? TextRange.empty, + )); } /// Inserts the given [text] at the current caret position, extending whatever /// attributions exist at the offset before the insertion. /// /// The caret is moved to the end of the inserted [text]. - /// - /// If the current [selection] is expanded, this method does nothing, - /// because there is no conceptual caret with an expanded selection. @override void insertAtCaretWithUpstreamAttributions({ required String text, TextRange? newComposingRegion, }) { - if (!selection.isCollapsed) { - textFieldLog.warning('Attempted to insert text at the caret with an expanded selection. Selection: $selection'); - return; + _ensureCaretReadyForInsertion(); + + final attributedText = AttributedText(text: text); + final attributions = _value.text.getAllAttributionsAt(max(selection.extentOffset - 1, 0)); + for (final attribution in attributions) { + attributedText.addAttribution( + attribution, + SpanRange(start: 0, end: attributedText.text.length - 1), + ); } - // TODO: create a command + _value.execute(InsertTextAtCaretCommand( + attributedText, + composingRegion: newComposingRegion ?? TextRange.empty, + )); } - /// Inserts the given [attributedText] at the current caret position. - /// - /// The caret is moved to the end of the inserted [text]. - /// - /// If the current [selection] is expanded, this method does nothing, - /// because there is no conceptual caret with an expanded selection. + /// Inserts the given [text] at the current caret position without any + /// attributions applied to the [text]. @override - void insertAttributedTextAtCaret({ - required AttributedText attributedText, + void insertAtCaretUnstyled({ + required String text, TextRange? newComposingRegion, }) { - if (!selection.isCollapsed) { - textFieldLog.warning('Attempted to insert text at the caret with an expanded selection. Selection: $selection'); - return; - } - - // TODO: create a command + insertAttributedTextAtCaret( + attributedText: AttributedText(text: text), + newComposingRegion: newComposingRegion, + ); } - /// Inserts the given [text] at the current caret position without any - /// attributions applied to the [text]. + /// Inserts the given [attributedText] at the current caret position. /// - /// If the current [selection] is expanded, this method does nothing, - /// because there is no conceptual caret with an expanded selection. + /// The caret is moved to the end of the inserted [text]. @override - void insertAtCaretUnstyled({ - required String text, + void insertAttributedTextAtCaret({ + required AttributedText attributedText, TextRange? newComposingRegion, }) { - if (!selection.isCollapsed) { - textFieldLog.warning('Attempted to insert text at the caret with an expanded selection. Selection: $selection'); - return; - } + _ensureCaretReadyForInsertion(); - // TODO: create a command + _value.execute(InsertTextAtCaretCommand( + attributedText, + composingRegion: newComposingRegion ?? TextRange.empty, + )); } /// Inserts [newText], starting at the given [insertIndex]. @@ -282,7 +306,39 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements TextSelection? newSelection, TextRange? newComposingRegion, }) { - // TODO: create a command + late final TextSelection updatedSelection; + if (newSelection != null) { + updatedSelection = newSelection; + } else { + int newBaseOffset = selection.baseOffset; + if ((selection.baseOffset == insertIndex && selection.isCollapsed) || (selection.baseOffset > insertIndex)) { + newBaseOffset = selection.baseOffset + newText.text.length; + } + + final newExtentOffset = + selection.extentOffset >= insertIndex ? selection.extentOffset + newText.text.length : selection.extentOffset; + + updatedSelection = TextSelection( + baseOffset: newBaseOffset, + extentOffset: newExtentOffset, + ); + } + + _value.execute(InsertTextAtOffsetCommand( + textToInsert: newText, + insertionOffset: insertIndex, + selectionAfter: updatedSelection, + composingRegion: newComposingRegion ?? TextRange.empty, + )); + } + + void _ensureCaretReadyForInsertion() { + if (!selection.isValid) { + throw Exception("Attempted to insert text at the caret but there is no selection"); + } + if (!selection.isCollapsed) { + throw Exception('Attempted to insert text at the caret with an expanded selection. Selection: $selection'); + } } /// Replaces the currently selected text with [replacementText] and collapses diff --git a/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart b/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart index 3b5688916..6070dae5a 100644 --- a/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart +++ b/super_editor/test/super_textfield/infrastructure/editing/event_source_controller_test.dart @@ -1,24 +1,299 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor/src/default_editor/attributions.dart'; +import 'package:super_editor/src/infrastructure/super_textfield/super_textfield.dart'; + +import '../../super_textfield_text_test_tools.dart'; void main() { group("EventSourcedAttributedTextEditingController", () { group("updates the entire value", () { - // TODO: + test("by selecting all text", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection.collapsed(offset: 27), // end of text + ), + ); + + // Ensure that we can select all text. + controller.selectAll(); + expect( + controller.selection, + const TextSelection(baseOffset: 0, extentOffset: 27), + ); + + // Ensure that we can undo the selection. + controller.undo(); + expect(controller.selection, const TextSelection.collapsed(offset: 27)); + }); + + test("by replacing the text and selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection.collapsed(offset: 27), // end of text + ), + ); + + // Replace all the contents + controller.updateTextAndSelection( + text: AttributedText(text: "This is new text"), + selection: const TextSelection(baseOffset: 8, extentOffset: 11), + composingRegion: const TextRange(start: 8, end: 12), + ); + + // Ensure that all the properties were updated. + expect(controller.text.text, "This is new text"); + expect(controller.selection, const TextSelection(baseOffset: 8, extentOffset: 11)); + expect(controller.composingRegion, const TextRange(start: 8, end: 12)); + + // Ensure that we can undo the entire replacement. + controller.undo(); + expect(controller.text.text, "This is some existing text."); + expect(controller.selection, const TextSelection.collapsed(offset: 27)); + expect(controller.composingRegion, TextRange.empty); + }); }); - group("inserts text", () { - // TODO: + group("changes attributions", () { + test("by toggling on selected text", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is styled text.", + ), + selection: const TextSelection(baseOffset: 8, extentOffset: 13), + ), + ); + + // Toggle the selected attributions on. + controller.toggleSelectionAttributions([boldAttribution]); + + // Ensure the attribution was added to the selection. + expect(controller.text, equalsMarkdown("This is **styled** text.")); + + // Ensure that we can undo the toggle. + controller.undo(); + expect(controller.text, equalsMarkdown("This is styled text.")); + }); + + test("by toggling off selected text", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is styled text.", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 13, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection(baseOffset: 9, extentOffset: 12), + ), + ); + + // Toggle the selected attributions off + controller.toggleSelectionAttributions([boldAttribution]); + + // Ensure the attribution was removed from the selection. + expect(controller.text, equalsMarkdown("This is **s**tyle**d** text.")); + + // Ensure that we can undo the toggle. + controller.undo(); + expect(controller.text, equalsMarkdown("This is **styled** text.")); + + // We're skipping this test because AttributedText.toggleAttributions() + // has a bug where it doesn't merge markers at the end of the range. + // What we're getting after undo'ing is: "This is **style****d** text.". + }, skip: true); + + test("by clearing from selected text", () { + // TODO: + }); }); - group("deletes text", () { + group("moves the caret", () { // TODO: }); - group("moves the caret", () { + group("inserts text", () { + test("character by character", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "a", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection.collapsed(offset: 1), + ), + ); + + controller + ..insertCharacter("b") + ..insertCharacter("c") + ..insertCharacter("d"); + + expect(controller.text, equalsMarkdown("**abcd**")); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + }); + + test("with newlines", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "a", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection.collapsed(offset: 1), + ), + ); + + controller + ..insertCharacter("b") + ..insertNewline() + ..insertCharacter("c"); + + expect(controller.text, equalsMarkdown("**ab\nc**")); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + }); + + test("at the caret without styles", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "a", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection.collapsed(offset: 1), + ), + ); + + controller + ..insertAtCaretUnstyled(text: "bc") + ..insertAtCaretUnstyled(text: "d"); + + expect(controller.text, equalsMarkdown("**a**bcd")); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + }); + + test("at the caret with composing attributions", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText(text: "a"), + selection: const TextSelection.collapsed(offset: 1), + ), + ); + + controller + ..addComposingAttributions({boldAttribution}) + ..insertAtCaret(text: "bc") + ..insertAtCaret(text: "d"); + + expect(controller.text, equalsMarkdown("a**bcd**")); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + }); + + test("at the caret with upstream attributions", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "a", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection.collapsed(offset: 1), + ), + ); + + controller + ..insertAtCaretWithUpstreamAttributions(text: "bc") + ..insertAtCaretWithUpstreamAttributions(text: "d"); + + expect(controller.text, equalsMarkdown("**abcd**")); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + }); + + test("at arbitrary offsets and automatically pushes the caret", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection.collapsed(offset: 27), // end of text + ), + ); + + controller.insert( + newText: AttributedText(text: " (modified)"), + insertIndex: 21, + ); + + expect(controller.text.text, "This is some existing (modified) text."); + expect(controller.selection, const TextSelection.collapsed(offset: 38)); + }); + + test("at arbitrary offsets and automatically expands the selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + // Select the space between "existing" and "text", which + // should expand when the new text is added. + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + controller.insert( + newText: AttributedText(text: " (modified)"), + insertIndex: 21, + ); + + expect(controller.text.text, "This is some existing (modified) text."); + expect( + controller.selection, + const TextSelection( + baseOffset: 21, + extentOffset: 33, + ), + ); + }); + }); + + group("replaces text", () { // TODO: }); - group("moves an expanded selection", () { + group("deletes text", () { // TODO: }); }); diff --git a/super_editor/test/super_textfield/infrastructure/editing/event_source_value_test.dart b/super_editor/test/super_textfield/infrastructure/editing/event_source_value_test.dart index 1dc71a1bb..83e243b70 100644 --- a/super_editor/test/super_textfield/infrastructure/editing/event_source_value_test.dart +++ b/super_editor/test/super_textfield/infrastructure/editing/event_source_value_test.dart @@ -15,9 +15,9 @@ void main() { ); editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "a"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "b"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "c"))); + ..execute(InsertTextAtCaretCommand(AttributedText(text: "a"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "b"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "c"))); expect(editingValue.text.text, "abc"); expect(editingValue.selection, const TextSelection.collapsed(offset: 3)); @@ -32,9 +32,9 @@ void main() { ); editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "a"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "b"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "c"))); + ..execute(InsertTextAtCaretCommand(AttributedText(text: "a"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "b"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "c"))); // Undo the insertions. editingValue.undo(); @@ -75,14 +75,14 @@ void main() { // Run a series of insertions, undo's, and redo's to ensure that // we can redo operations at various times. editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "a"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "a"))) ..undo() ..redo(); expect(editingValue.text.text, "a"); editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "b"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "c"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "b"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "c"))) ..undo() ..undo() ..redo(); @@ -116,11 +116,11 @@ void main() { // Run a series of insertions, undo's, and redo's to ensure that // we can redo operations at various times. editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "a"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "b"))) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "c"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "a"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "b"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "c"))) ..undo() - ..execute(TextFieldInsertTextCommand(AttributedText(text: "d"))); + ..execute(InsertTextAtCaretCommand(AttributedText(text: "d"))); expect(editingValue.text.text, "abd"); expect(editingValue.isRedoable, isFalse); @@ -136,12 +136,12 @@ void main() { // Run a batch command with multiple inner commands. editingValue - ..execute(TextFieldInsertTextCommand(AttributedText(text: "a"))) + ..execute(InsertTextAtCaretCommand(AttributedText(text: "a"))) ..execute(BatchCommand([ - TextFieldInsertTextCommand(AttributedText(text: "b")), - TextFieldInsertTextCommand(AttributedText(text: "c")), + InsertTextAtCaretCommand(AttributedText(text: "b")), + InsertTextAtCaretCommand(AttributedText(text: "c")), ])) - ..execute(TextFieldInsertTextCommand(AttributedText(text: "d"))); + ..execute(InsertTextAtCaretCommand(AttributedText(text: "d"))); expect(editingValue.text.text, "abcd"); // Undo the batch command. diff --git a/super_editor/test/super_textfield/super_textfield_text_test_tools.dart b/super_editor/test/super_textfield/super_textfield_text_test_tools.dart new file mode 100644 index 000000000..c58a56f14 --- /dev/null +++ b/super_editor/test/super_textfield/super_textfield_text_test_tools.dart @@ -0,0 +1,59 @@ +import 'package:attributed_text/attributed_text.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:super_editor_markdown/super_editor_markdown.dart'; + +/// [Matcher] that expects a target [AttributedText] to have content +/// and styles and match the given [markdown]. +Matcher equalsMarkdown(String markdown) => TextEqualsMarkdownMatcher(markdown); + +class TextEqualsMarkdownMatcher extends Matcher { + const TextEqualsMarkdownMatcher(this._expectedMarkdown); + + final String _expectedMarkdown; + + @override + Description describe(Description description) { + return description.add("given text has equivalent content to the given markdown"); + } + + @override + bool matches(covariant Object target, Map matchState) { + return _calculateMismatchReason(target, matchState) == null; + } + + @override + Description describeMismatch( + covariant Object target, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final mismatchReason = _calculateMismatchReason(target, matchState); + if (mismatchReason != null) { + mismatchDescription.add(mismatchReason); + } + return mismatchDescription; + } + + String? _calculateMismatchReason( + Object target, + Map matchState, + ) { + late AttributedText actualText; + if (target is! AttributedText) { + return "the given target isn't an AttributedText: $target"; + } + actualText = target; + + final actualMarkdown = actualText.toMarkdown(); + final stringMatcher = equals(_expectedMarkdown); + final matcherState = {}; + final matches = stringMatcher.matches(actualMarkdown, matcherState); + if (matches) { + // The document matches the markdown. Our matcher matches. + return null; + } + + return stringMatcher.describeMismatch(actualMarkdown, StringDescription(), matchState, false).toString(); + } +} diff --git a/super_editor_markdown/lib/src/markdown.dart b/super_editor_markdown/lib/src/markdown.dart index d33da4e95..fda3a2fee 100644 --- a/super_editor_markdown/lib/src/markdown.dart +++ b/super_editor_markdown/lib/src/markdown.dart @@ -411,7 +411,7 @@ class _InlineMarkdownToDocument implements md.NodeVisitor { } } -extension on AttributedText { +extension Markdown on AttributedText { /// Serializes style attributions into markdown syntax in a repeatable /// order such that opening and closing styles match each other on /// the opening and closing ends of a span.