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 74de3a75d..81d6f841f 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 @@ -70,6 +70,47 @@ class ToggleSelectionAttributionsCommand extends AttributedTextEditingValueComma } } +/// Removes all attributions within the current selection. +class RemoveSelectedAttributionsCommand extends AttributedTextEditingValueCommand { + RemoveSelectedAttributionsCommand(); + + Set? _previousAttributionSpans; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + final selectionRange = previousValue.selection.toSpanRange(); + _previousAttributionSpans = + previousValue.text.getAttributionSpansInRange(attributionFilter: (_) => true, range: selectionRange).toSet(); + final newText = previousValue.text.copy()..clearAttributions(selectionRange); + + return AttributedTextEditingValue( + text: newText, + selection: previousValue.selection, + composingRegion: previousValue.composingRegion, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + final revertedText = currentValue.text.copy(); + for (final attributionSpan in _previousAttributionSpans!) { + revertedText.addAttribution( + attributionSpan.attribution, + SpanRange( + start: attributionSpan.start, + end: attributionSpan.end, + ), + ); + } + + 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. @@ -102,7 +143,7 @@ class InsertTextAtCaretCommand extends AttributedTextEditingValueCommand { selection: TextSelection.collapsed( offset: previousValue.selection.extentOffset + textToInsert.text.length, ), - composingRegion: composingRegion ?? TextRange.empty, + composingRegion: composingRegion ?? _previousComposingRegion!, ); return newValue; @@ -163,7 +204,7 @@ class InsertTextAtOffsetCommand extends AttributedTextEditingValueCommand { startOffset: insertionOffset, ), selection: selectionAfter, - composingRegion: composingRegion ?? TextRange.empty, + composingRegion: composingRegion ?? _previousComposingRegion!, ); return newValue; @@ -196,9 +237,55 @@ extension on AttributedText { } } +/// Deletes the currently selected text, collapsing the selection to a caret +/// at the selection base. +class DeleteSelectedTextCommand extends AttributedTextEditingValueCommand { + DeleteSelectedTextCommand({ + this.newComposingRegion, + }); + + final TextRange? newComposingRegion; + + AttributedText? _deletedText; + TextSelection? _previousSelection; + TextRange? _previousComposingRegion; + + @override + AttributedTextEditingValue doExecute(AttributedTextEditingValue previousValue) { + _deletedText = previousValue.text.copyText( + previousValue.selection.start, + previousValue.selection.end, + ); + + _previousSelection = previousValue.selection; + _previousComposingRegion = previousValue.composingRegion; + + return AttributedTextEditingValue( + text: previousValue.text.removeRegion( + startOffset: previousValue.selection.start, + endOffset: previousValue.selection.end, + ), + selection: TextSelection.collapsed(offset: previousValue.selection.baseOffset), + composingRegion: newComposingRegion ?? _previousComposingRegion!, + ); + } + + @override + AttributedTextEditingValue doUndo(AttributedTextEditingValue currentValue) { + return AttributedTextEditingValue( + text: currentValue.text.insert( + textToInsert: _deletedText!, + startOffset: _previousSelection!.baseOffset, + ), + selection: _previousSelection!, + composingRegion: _previousComposingRegion!, + ); + } +} + /// Replaces the text, selection, and composing region in the editable. -class ReplaceContentCommands extends AttributedTextEditingValueCommand { - ReplaceContentCommands({ +class ReplaceEverythingCommand extends AttributedTextEditingValueCommand { + ReplaceEverythingCommand({ required this.newText, required this.newSelection, this.newComposingRegion = TextRange.empty, 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 be5601dea..1a9366f86 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 @@ -14,6 +14,7 @@ import '../attributed_text_editing_value.dart'; import 'commands.dart'; import 'event_source_value.dart'; +/// An [AttributedTextEditingController] that supports undo/redo. class EventSourcedAttributedTextEditingController with ChangeNotifier implements AttributedTextEditingController { EventSourcedAttributedTextEditingController( AttributedTextEditingValue initialValue, @@ -21,12 +22,18 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements final EventSourcedAttributedTextEditingValue _value; + /// Whether there are any commands in the history stack. bool get isUndoable => _value.isUndoable; + /// Pops the top command off the history stack and reverses its + /// effect on the current attributed text editing value. bool undo() => _value.undo(); + /// Whether there are any commands in the future stack. bool get isRedoable => _value.isRedoable; + /// Pops the top command off the future stack and re-applies its + /// effect on the current attributed text editing value. bool redo() => _value.redo(); @override @@ -50,7 +57,7 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements @override set composingRegion(TextRange _) => throw UnimplementedError(); - // TODO: this should probably an extension method on AttributedText or something + // TODO: this should probably be an extension method on AttributedText or something // like that. @override bool isSelectionWithinTextBounds(TextSelection selection) { @@ -177,7 +184,7 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements return; } - // TODO: create a command + _value.execute(RemoveSelectedAttributionsCommand()); } @override @@ -192,7 +199,7 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements TextRange composingRegion = TextRange.empty, }) { _value.execute( - ReplaceContentCommands( + ReplaceEverythingCommand( newText: text, newSelection: selection, newComposingRegion: composingRegion, @@ -354,7 +361,19 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements return; } - // TODO: create a command + final upstreamAttributions = _value.text.getAllAttributionsAt( + max(selection.start - 1, 0), + ); + final newStyledText = AttributedText(text: replacementText); + final newTextRange = SpanRange(start: 0, end: newStyledText.text.length - 1); + for (final attribution in upstreamAttributions) { + newStyledText.addAttribution(attribution, newTextRange); + } + + replaceSelectionWithAttributedText( + attributedReplacementText: newStyledText, + newComposingRegion: newComposingRegion, + ); } /// Replaces the currently selected text with [attributedReplacementText] and @@ -370,7 +389,10 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements return; } - // TODO: create a command + _value.execute(BatchCommand([ + DeleteSelectedTextCommand(), + InsertTextAtCaretCommand(attributedReplacementText, composingRegion: newComposingRegion), + ])); } /// Replaces the currently selected text with un-styled [text] and collapses @@ -386,7 +408,10 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements return; } - // TODO: create a command + _value.execute(BatchCommand([ + DeleteSelectedTextCommand(), + InsertTextAtCaretCommand(AttributedText(text: replacementText), composingRegion: newComposingRegion), + ])); } /// Removes the text between [from] (inclusive) and [to] (exclusive), and replaces that @@ -409,6 +434,8 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } + /// Deletes all the text on the current line that appears upstream from the + /// caret. @override void deleteTextOnLineBeforeCaret({ required ProseTextLayout textLayout, @@ -418,6 +445,7 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } + // TODO: either this method or the next one should be deleted @override void deleteSelectedText() { assert(!selection.isCollapsed); @@ -425,6 +453,20 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } + /// Deletes the text within the current [selection]. + /// + /// Does nothing if [selection] is collapsed. + @override + void deleteSelection({ + TextRange? newComposingRegion, + }) { + if (selection.isCollapsed) { + return; + } + + // TODO: create a command + } + @override void deleteCharacter(TextAffinity direction) { assert(selection.isCollapsed); @@ -476,20 +518,6 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } - /// Deletes the text within the current [selection]. - /// - /// Does nothing if [selection] is collapsed. - @override - void deleteSelection({ - TextRange? newComposingRegion, - }) { - if (selection.isCollapsed) { - return; - } - - // TODO: create a command - } - /// Removes the text between [from] (inclusive) and [to] (exclusive). /// /// The [selection] is updated to [newSelection], if provided, otherwise @@ -508,18 +536,29 @@ class EventSourcedAttributedTextEditingController with ChangeNotifier implements // TODO: create a command } + /// Sets the text to empty and removes the selection and composing region. + @override + void clear() { + update( + text: AttributedText(text: ""), + selection: const TextSelection.collapsed(offset: -1), + composingRegion: TextRange.empty, + ); + } + @override void update({ AttributedText? text, TextSelection? selection, TextRange? composingRegion, }) { - // TODO: create a command - } - - @override - void clear() { - // TODO: create a command + _value.execute( + ReplaceEverythingCommand( + newText: text ?? this.text, + newSelection: selection ?? this.selection, + newComposingRegion: composingRegion ?? this.composingRegion, + ), + ); } @override 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 6070dae5a..71660473b 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 @@ -59,6 +59,10 @@ void main() { expect(controller.selection, const TextSelection.collapsed(offset: 27)); expect(controller.composingRegion, TextRange.empty); }); + + test("by clearing everything", () { + // TODO: + }); }); group("changes attributions", () { @@ -115,12 +119,43 @@ void main() { }, skip: true); test("by clearing from selected text", () { - // TODO: + 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), + ), + ); + + // Ensure that we can remove selected attributions. + controller.clearSelectionAttributions(); + expect(controller.text, equalsMarkdown("This is **s**tyle**d** text.")); + + // Ensure that we can undo it. + controller.undo(); + expect(controller.text, equalsMarkdown("This is **styled** text.")); }); }); group("moves the caret", () { - // TODO: + test("to a different position", () { + // TODO: + }); + + test("by expanding the selection", () { + // TODO: + }); + + test("by collapsing the selection", () { + // TODO: + }); }); group("inserts text", () { @@ -290,10 +325,250 @@ void main() { }); group("replaces text", () { - // TODO: + test("by replacing selection and extending upstream attributions", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 11, markerType: SpanMarkerType.end), + ], + ), + ), + selection: const TextSelection( + baseOffset: 12, + extentOffset: 21, + ), + ), + ); + + controller.replaceSelectionWithTextAndUpstreamAttributions(replacementText: " new"); + expect(controller.text, equalsMarkdown("This is **some new** text.")); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Undo it. + controller.undo(); + expect(controller.text, equalsMarkdown("This is **some** existing text.")); + expect( + controller.selection, + const TextSelection( + baseOffset: 12, + extentOffset: 21, + ), + ); + }); + + test("by replacing selection with new attributed text", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 13, + extentOffset: 21, + ), + ), + ); + + controller.replaceSelectionWithAttributedText( + attributedReplacementText: AttributedText( + text: "new", + spans: AttributedSpans( + attributions: [ + const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start), + const SpanMarker(attribution: boldAttribution, offset: 2, markerType: SpanMarkerType.end), + ], + ), + ), + ); + expect(controller.text, equalsMarkdown("This is some **new** text.")); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Undo it. + controller.undo(); + expect(controller.text, equalsMarkdown("This is some existing text.")); + expect( + controller.selection, + const TextSelection( + baseOffset: 13, + extentOffset: 21, + ), + ); + }); + + test("by replacing selection with unstyled text", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 13, + extentOffset: 21, + ), + ), + ); + + controller.replaceSelectionWithUnstyledText(replacementText: "new"); + expect(controller.text, equalsMarkdown("This is some new text.")); + expect(controller.selection, const TextSelection.collapsed(offset: 16)); + + // Undo it. + controller.undo(); + expect(controller.text, equalsMarkdown("This is some existing text.")); + expect( + controller.selection, + const TextSelection( + baseOffset: 13, + extentOffset: 21, + ), + ); + }); + + test("by replacing arbitrary text away from the caret", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + // TODO: + // controller.replace( + // newText: newText, + // from: from, + // to: to, + // ); + }); + + test("by replacing arbitrary text that overlaps the caret", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + // TODO: + // controller.replace( + // newText: newText, + // from: from, + // to: to, + // ); + }); + + test("by replacing arbitrary text away from an expanded selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + // TODO: + // controller.replace( + // newText: newText, + // from: from, + // to: to, + // ); + }); + + test("by replacing arbitrary text contained within an expanded selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + // TODO: + // controller.replace( + // newText: newText, + // from: from, + // to: to, + // ); + }); + + test("by replacing arbitrary text that overlaps an expanded selection", () { + final controller = EventSourcedAttributedTextEditingController( + AttributedTextEditingValue( + text: AttributedText( + text: "This is some existing text.", + ), + selection: const TextSelection( + baseOffset: 21, + extentOffset: 22, + ), + ), + ); + + // TODO: + // controller.replace( + // newText: newText, + // from: from, + // to: to, + // ); + }); }); group("deletes text", () { + test("between the caret and the beginning of the line", () { + // TODO: + }); + + test("when its selected", () { + // TODO: + }); + + test("by character", () { + // TODO: + }); + + test("that sits away from the caret", () { + // TODO: + }); + + test("that overlaps the caret", () { + // TODO: + }); + + test("that sits away from an expanded selection", () { + // TODO: + }); + + test("that sits within an expanded selection", () { + // TODO: + }); + + test("that overlaps an expanded selection", () { + // TODO + }); + }); + + test("pastes text from clipboard", () { // TODO: }); });