diff --git a/super_editor/clones/quill/lib/app.dart b/super_editor/clones/quill/lib/app.dart index 648371e7f..f7c573f60 100644 --- a/super_editor/clones/quill/lib/app.dart +++ b/super_editor/clones/quill/lib/app.dart @@ -122,9 +122,9 @@ This is a code block. /// current block. This is especially important for a code block, in which pressing /// Enter inserts a newline inside the code block - it doesn't insert a new paragraph /// below the code block. -class _AlwaysTrailingParagraphReaction implements EditReaction { +class _AlwaysTrailingParagraphReaction extends EditReaction { @override - void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { final document = editorContext.find(Editor.documentKey); final lastNode = document.nodes.lastOrNull; diff --git a/super_editor/clones/quill/lib/editor/editor.dart b/super_editor/clones/quill/lib/editor/editor.dart index ca9f44e57..21d1c546e 100644 --- a/super_editor/clones/quill/lib/editor/editor.dart +++ b/super_editor/clones/quill/lib/editor/editor.dart @@ -126,7 +126,7 @@ class ClearSelectedStylesRequest implements EditRequest { const ClearSelectedStylesRequest(); } -class ClearSelectedStylesCommand implements EditCommand { +class ClearSelectedStylesCommand extends EditCommand { const ClearSelectedStylesCommand(); @override @@ -181,7 +181,7 @@ class ClearTextAttributionsRequest implements EditRequest { int get hashCode => documentRange.hashCode; } -class ClearTextAttributionsCommand implements EditCommand { +class ClearTextAttributionsCommand extends EditCommand { const ClearTextAttributionsCommand(this.documentRange); final DocumentRange documentRange; @@ -288,7 +288,7 @@ class ToggleInlineFormatRequest implements EditRequest { int get hashCode => inlineFormat.hashCode; } -class ToggleInlineFormatCommand implements EditCommand { +class ToggleInlineFormatCommand extends EditCommand { const ToggleInlineFormatCommand(this.inlineFormat); final Attribution inlineFormat; diff --git a/super_editor/clones/quill/lib/editor/toolbar.dart b/super_editor/clones/quill/lib/editor/toolbar.dart index d46df9fbe..3099430a2 100644 --- a/super_editor/clones/quill/lib/editor/toolbar.dart +++ b/super_editor/clones/quill/lib/editor/toolbar.dart @@ -185,7 +185,7 @@ class _FormattingToolbarState extends State { ]); // Clear the field and hide the URL bar - _urlController!.clear(); + _urlController!.clearTextAndSelection(); _urlFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); _linkPopoverController.close(); setState(() {}); @@ -220,7 +220,7 @@ class _FormattingToolbarState extends State { } // Clear the field and hide the URL bar - _imageController!.clear(); + _imageController!.clearTextAndSelection(); _imageFocusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); _imagePopoverController.close(); setState(() {}); @@ -535,7 +535,7 @@ class _FormattingToolbarState extends State { onPressed: () { setState(() { _urlFocusNode.unfocus(); - _urlController!.clear(); + _urlController!.clearTextAndSelection(); }); }, ), @@ -605,7 +605,7 @@ class _FormattingToolbarState extends State { onPressed: () { setState(() { _imageFocusNode.unfocus(); - _imageController!.clear(); + _imageController!.clearTextAndSelection(); }); }, ), diff --git a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart index f2a615cfd..071b7d1d5 100644 --- a/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart +++ b/super_editor/example/lib/demos/in_the_lab/feature_action_tags.dart @@ -298,7 +298,7 @@ class ConvertSelectedTextNodeRequest implements EditRequest { int get hashCode => newType.hashCode; } -class ConvertSelectedTextNodeCommand implements EditCommand { +class ConvertSelectedTextNodeCommand extends EditCommand { ConvertSelectedTextNodeCommand(this.newType); final TextNodeType newType; diff --git a/super_editor/example/lib/main_super_editor.dart b/super_editor/example/lib/main_super_editor.dart index 1f027746f..dcc7d6a08 100644 --- a/super_editor/example/lib/main_super_editor.dart +++ b/super_editor/example/lib/main_super_editor.dart @@ -1,5 +1,4 @@ import 'package:example/demos/example_editor/_example_document.dart'; -import 'package:example/demos/example_editor/example_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -31,7 +30,7 @@ void main() { runApp( MaterialApp( home: Scaffold( - body: ExampleEditor(), + body: _Demo(), ), supportedLocales: const [ Locale('en', ''), @@ -48,8 +47,164 @@ void main() { ); } +class _Demo extends StatefulWidget { + const _Demo(); + + @override + State<_Demo> createState() => _DemoState(); +} + +class _DemoState extends State<_Demo> { + late MutableDocument _document; + late MutableDocumentComposer _composer; + late Editor _docEditor; + + @override + void initState() { + super.initState(); + _document = createInitialDocument(); + _composer = MutableDocumentComposer(); + _docEditor = createDefaultDocumentEditor(document: _document, composer: _composer); + } + + @override + void dispose() { + _composer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _StandardEditor( + document: _document, + composer: _composer, + editor: _docEditor, + ), + ), + _buildToolbar(), + ], + ); + } + + Widget _buildToolbar() { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _EditorHistoryPanel(editor: _docEditor), + Container( + width: 24, + height: double.infinity, + color: const Color(0xFF2F2F2F), + child: Column(), + ), + ], + ); + } +} + +class _EditorHistoryPanel extends StatefulWidget { + const _EditorHistoryPanel({ + required this.editor, + }); + + final Editor editor; + + @override + State<_EditorHistoryPanel> createState() => _EditorHistoryPanelState(); +} + +class _EditorHistoryPanelState extends State<_EditorHistoryPanel> { + final _scrollController = ScrollController(); + late EditListener _editListener; + + @override + void initState() { + super.initState(); + + _editListener = FunctionalEditListener(_onEditorChange); + widget.editor.addListener(_editListener); + } + + @override + void didUpdateWidget(_EditorHistoryPanel oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.editor != oldWidget.editor) { + oldWidget.editor.removeListener(_editListener); + widget.editor.addListener(_editListener); + } + } + + @override + void dispose() { + _scrollController.dispose(); + widget.editor.removeListener(_editListener); + super.dispose(); + } + + void _onEditorChange(changes) { + setState(() { + // Build the latest list of changes. + }); + + // Always scroll to bottom of transaction list. + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController.position.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData( + brightness: Brightness.dark, + ), + child: Container( + width: 300, + height: double.infinity, + color: const Color(0xFF333333), + child: SingleChildScrollView( + controller: _scrollController, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Column( + children: [ + for (final history in widget.editor.history) + ListTile( + title: Text("${history.changes.length} changes"), + titleTextStyle: TextStyle( + fontSize: 16, + ), + subtitle: Text("${history.changes.map((event) => event.describe()).join("\n")}"), + subtitleTextStyle: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 10, + height: 1.4, + ), + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ), + ), + ); + } +} + class _StandardEditor extends StatefulWidget { - const _StandardEditor(); + const _StandardEditor({ + required this.document, + required this.composer, + required this.editor, + }); + + final MutableDocument document; + final MutableDocumentComposer composer; + final Editor editor; @override State<_StandardEditor> createState() => _StandardEditorState(); @@ -58,10 +213,6 @@ class _StandardEditor extends StatefulWidget { class _StandardEditorState extends State<_StandardEditor> { final GlobalKey _docLayoutKey = GlobalKey(); - late MutableDocument _doc; - late MutableDocumentComposer _composer; - late Editor _docEditor; - late FocusNode _editorFocusNode; late ScrollController _scrollController; @@ -69,9 +220,6 @@ class _StandardEditorState extends State<_StandardEditor> { @override void initState() { super.initState(); - _doc = createInitialDocument(); - _composer = MutableDocumentComposer(); - _docEditor = createDefaultDocumentEditor(document: _doc, composer: _composer); _editorFocusNode = FocusNode(); _scrollController = ScrollController(); } @@ -80,19 +228,27 @@ class _StandardEditorState extends State<_StandardEditor> { void dispose() { _scrollController.dispose(); _editorFocusNode.dispose(); - _composer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SuperEditor( - editor: _docEditor, - document: _doc, - composer: _composer, + editor: widget.editor, + document: widget.document, + composer: widget.composer, focusNode: _editorFocusNode, scrollController: _scrollController, documentLayoutKey: _docLayoutKey, + stylesheet: defaultStylesheet.copyWith( + addRulesAfter: [ + taskStyles, + ], + ), + componentBuilders: [ + TaskComponentBuilder(widget.editor), + ...defaultComponentBuilders, + ], ); } } diff --git a/super_editor/example_docs/lib/toolbar.dart b/super_editor/example_docs/lib/toolbar.dart index 9698c148d..d7faed25f 100644 --- a/super_editor/example_docs/lib/toolbar.dart +++ b/super_editor/example_docs/lib/toolbar.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:example_docs/editor.dart'; import 'package:example_docs/infrastructure/icon_selector.dart'; import 'package:example_docs/infrastructure/color_selector.dart'; import 'package:example_docs/infrastructure/text_item_selector.dart'; diff --git a/super_editor/example_docs/pubspec.lock b/super_editor/example_docs/pubspec.lock index 48318ee4f..83961bfa6 100644 --- a/super_editor/example_docs/pubspec.lock +++ b/super_editor/example_docs/pubspec.lock @@ -136,6 +136,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -243,26 +251,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" linkify: dependency: transitive description: @@ -291,10 +299,10 @@ packages: dependency: transitive description: name: markdown - sha256: "01512006c8429f604eb10f9848717baeaedf99e991d14a50d540d9beff08e5c6" + sha256: "39caf989ccc72c63e87b961851a74257141938599ed2db45fbd9403fee0db423" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "5.0.0" matcher: dependency: transitive description: @@ -315,10 +323,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" mime: dependency: transitive description: @@ -336,13 +344,13 @@ packages: source: hosted version: "2.0.2" overlord: - dependency: transitive + dependency: "direct main" description: name: overlord - sha256: "311b50446ec227beafc114968101ae623046cf27887f43c916fa7c5131a145b6" + sha256: "576256bc9ce3fb0ae3042bbb26eed67bdb26a5045dd7e3c851aae65b0bbab2f5" url: "https://pub.dev" source: hosted - version: "0.0.3+4" + version: "0.0.3+5" package_config: dependency: transitive description: @@ -500,6 +508,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -557,26 +573,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.24.9" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.5.9" typed_data: dependency: transitive description: @@ -653,10 +669,10 @@ packages: dependency: transitive description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.4.0" vector_math: dependency: transitive description: @@ -669,10 +685,10 @@ packages: dependency: transitive description: name: vm_service - sha256: e7d5ecd604e499358c5fe35ee828c0298a320d54455e791e9dcf73486bc8d9f0 + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.1.0" + version: "13.0.0" watcher: dependency: transitive description: @@ -730,5 +746,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.3.0-279.1.beta <4.0.0" + flutter: ">=3.16.0" diff --git a/super_editor/lib/src/core/document.dart b/super_editor/lib/src/core/document.dart index 8c3f37642..7dd29c122 100644 --- a/super_editor/lib/src/core/document.dart +++ b/super_editor/lib/src/core/document.dart @@ -99,16 +99,21 @@ class DocumentChangeLog { /// Marker interface for all document changes. abstract class DocumentChange { - // Marker interface + const DocumentChange(); + + /// Describes this change in a human-readable way. + String describe() => toString(); } /// A [DocumentChange] that impacts a single, specified [DocumentNode] with [nodeId]. -abstract class NodeDocumentChange implements DocumentChange { +abstract class NodeDocumentChange extends DocumentChange { + const NodeDocumentChange(); + String get nodeId; } /// A new [DocumentNode] was inserted in the [Document]. -class NodeInsertedEvent implements NodeDocumentChange { +class NodeInsertedEvent extends NodeDocumentChange { const NodeInsertedEvent(this.nodeId, this.insertionIndex); @override @@ -116,6 +121,9 @@ class NodeInsertedEvent implements NodeDocumentChange { final int insertionIndex; + @override + String describe() => "Inserted node: $nodeId"; + @override String toString() => "NodeInsertedEvent ($nodeId)"; @@ -132,7 +140,7 @@ class NodeInsertedEvent implements NodeDocumentChange { } /// A [DocumentNode] was moved to a new index. -class NodeMovedEvent implements NodeDocumentChange { +class NodeMovedEvent extends NodeDocumentChange { const NodeMovedEvent({ required this.nodeId, required this.from, @@ -144,6 +152,9 @@ class NodeMovedEvent implements NodeDocumentChange { final int from; final int to; + @override + String describe() => "Moved node ($nodeId): $from -> $to"; + @override String toString() => "NodeMovedEvent ($nodeId: $from -> $to)"; @@ -161,7 +172,7 @@ class NodeMovedEvent implements NodeDocumentChange { } /// A [DocumentNode] was removed from the [Document]. -class NodeRemovedEvent implements NodeDocumentChange { +class NodeRemovedEvent extends NodeDocumentChange { const NodeRemovedEvent(this.nodeId, this.removedNode); @override @@ -169,6 +180,9 @@ class NodeRemovedEvent implements NodeDocumentChange { final DocumentNode removedNode; + @override + String describe() => "Removed node: $nodeId"; + @override String toString() => "NodeRemovedEvent ($nodeId)"; @@ -185,12 +199,15 @@ class NodeRemovedEvent implements NodeDocumentChange { /// A node change might signify a content change, such as text changing in a paragraph, or /// it might signify a node changing its type of content, such as converting a paragraph /// to an image. -class NodeChangeEvent implements NodeDocumentChange { +class NodeChangeEvent extends NodeDocumentChange { const NodeChangeEvent(this.nodeId); @override final String nodeId; + @override + String describe() => "Changed node: $nodeId"; + @override String toString() => "NodeChangeEvent ($nodeId)"; @@ -381,6 +398,8 @@ abstract class DocumentNode implements ChangeNotifier { /// Returns a copy of this node's metadata. Map copyMetadata() => Map.from(_metadata); + DocumentNode copy(); + @override bool operator ==(Object other) => identical(this, other) || diff --git a/super_editor/lib/src/core/document_composer.dart b/super_editor/lib/src/core/document_composer.dart index 502ae0855..5aed36b75 100644 --- a/super_editor/lib/src/core/document_composer.dart +++ b/super_editor/lib/src/core/document_composer.dart @@ -108,6 +108,7 @@ class MutableDocumentComposer extends DocumentComposer implements Editable { bool _isInTransaction = false; bool _didChangeSelectionDuringTransaction = false; + bool _didReset = false; /// Sets the current [selection] for a [Document]. /// @@ -140,9 +141,7 @@ class MutableDocumentComposer extends DocumentComposer implements Editable { @override void onTransactionStart() { _selectionNotifier.pauseNotifications(); - _composingRegion.pauseNotifications(); - _isInInteractionMode.pauseNotifications(); _isInTransaction = true; @@ -157,10 +156,25 @@ class MutableDocumentComposer extends DocumentComposer implements Editable { if (_latestSelectionChange != null && _didChangeSelectionDuringTransaction) { _streamController.sink.add(_latestSelectionChange!); } - _composingRegion.resumeNotifications(); - _isInInteractionMode.resumeNotifications(); + + if (_didReset) { + // Our state was reset (possibly for to undo an operation). Anything may have changed. + // Force notify all listeners. + _didReset = false; + _selectionNotifier.notifyListeners(); + _composingRegion.notifyListeners(); + _isInInteractionMode.notifyListeners(); + } + } + + @override + void reset() { + _selectionNotifier.value = null; + _latestSelectionChange = null; + _composingRegion.value = null; + _didReset = true; } } @@ -325,7 +339,7 @@ class ChangeSelectionRequest implements EditRequest { /// An [EditCommand] that changes the [DocumentSelection] in the [DocumentComposer] /// to the [newSelection]. -class ChangeSelectionCommand implements EditCommand { +class ChangeSelectionCommand extends EditCommand { const ChangeSelectionCommand( this.newSelection, this.changeType, @@ -343,6 +357,12 @@ class ChangeSelectionCommand implements EditCommand { final String reason; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => "Change selection ($changeType): $newSelection"; + @override void execute(EditContext context, CommandExecutor executor) { final composer = context.find(Editor.composerKey); @@ -362,7 +382,7 @@ class ChangeSelectionCommand implements EditCommand { } /// A [EditEvent] that represents a change to the user's selection within a document. -class SelectionChangeEvent implements EditEvent { +class SelectionChangeEvent extends EditEvent { const SelectionChangeEvent({ required this.oldSelection, required this.newSelection, @@ -376,12 +396,15 @@ class SelectionChangeEvent implements EditEvent { // TODO: can we replace the concept of a `reason` with `changeType` final String reason; + @override + String describe() => "Selection - ${changeType.name}, $reason"; + @override String toString() => "[SelectionChangeEvent] - New selection: $newSelection, change type: $changeType"; } /// A [EditEvent] that represents a change to the user's composing region within a document. -class ComposingRegionChangeEvent implements EditEvent { +class ComposingRegionChangeEvent extends EditEvent { const ComposingRegionChangeEvent({ required this.oldComposingRegion, required this.newComposingRegion, @@ -390,6 +413,9 @@ class ComposingRegionChangeEvent implements EditEvent { final DocumentRange? oldComposingRegion; final DocumentRange? newComposingRegion; + @override + String describe() => "Composing - ${newComposingRegion ?? "empty"}"; + @override String toString() => "[ComposingRegionChangeEvent] - New composing region: $newComposingRegion"; } @@ -479,11 +505,14 @@ class ChangeComposingRegionRequest implements EditRequest { int get hashCode => composingRegion.hashCode; } -class ChangeComposingRegionCommand implements EditCommand { +class ChangeComposingRegionCommand extends EditCommand { ChangeComposingRegionCommand(this.composingRegion); final DocumentRange? composingRegion; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final composer = context.find(Editor.composerKey); @@ -522,15 +551,39 @@ class ChangeInteractionModeRequest implements EditRequest { int get hashCode => isInteractionModeDesired.hashCode; } -class ChangeInteractionModeCommand implements EditCommand { +class ChangeInteractionModeCommand extends EditCommand { ChangeInteractionModeCommand({ required this.isInteractionModeDesired, }); final bool isInteractionModeDesired; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.nonHistorical; + @override void execute(EditContext context, CommandExecutor executor) { context.find(Editor.composerKey).setIsInteractionMode(isInteractionModeDesired); } } + +class RemoveComposerPreferenceStylesRequest implements EditRequest { + const RemoveComposerPreferenceStylesRequest(this.stylesToRemove); + + final Set stylesToRemove; +} + +class RemoveComposerPreferenceStylesCommand extends EditCommand { + RemoveComposerPreferenceStylesCommand(this._stylesToRemove); + + final Set _stylesToRemove; + + @override + final historyBehavior = HistoryBehavior.undoable; + + @override + void execute(EditContext context, CommandExecutor executor) { + final composer = context.find(Editor.composerKey); + composer.preferences.removeStyles(_stylesToRemove); + } +} diff --git a/super_editor/lib/src/core/document_layout.dart b/super_editor/lib/src/core/document_layout.dart index 4ec3d3ab5..a28c57fd6 100644 --- a/super_editor/lib/src/core/document_layout.dart +++ b/super_editor/lib/src/core/document_layout.dart @@ -23,11 +23,14 @@ class DocumentLayoutEditable implements Editable { DocumentLayout get documentLayout => _documentLayoutResolver(); + @override + void onTransactionStart() {} + @override void onTransactionEnd(List edits) {} @override - void onTransactionStart() {} + void reset() {} } /// Abstract representation of a document layout. diff --git a/super_editor/lib/src/core/document_selection.dart b/super_editor/lib/src/core/document_selection.dart index ac957c836..a4435ac2c 100644 --- a/super_editor/lib/src/core/document_selection.dart +++ b/super_editor/lib/src/core/document_selection.dart @@ -1,5 +1,7 @@ import 'dart:ui'; +import 'package:super_editor/src/default_editor/text.dart'; + import 'document.dart'; /// A selection within a [Document]. @@ -86,6 +88,20 @@ class DocumentSelection extends DocumentRange { @override String toString() { + if (base.nodeId == extent.nodeId) { + final basePosition = base.nodePosition; + final extentPosition = extent.nodePosition; + if (basePosition is TextNodePosition && extentPosition is TextNodePosition) { + if (basePosition.offset == extentPosition.offset) { + return "[Selection] - ${base.nodeId}: ${extentPosition.offset}"; + } + + return "[Selection] - ${base.nodeId}: [${basePosition.offset}, ${extentPosition.offset}]"; + } + + return "[Selection] - ${base.nodeId}: [${base.nodePosition}, ${extent.nodePosition}]"; + } + return '[DocumentSelection] - \n base: ($base),\n extent: ($extent)'; } @@ -283,7 +299,21 @@ class DocumentRange { @override String toString() { - return '[DocumentRange] - start: ($start), end: ($end)'; + if (start.nodeId == end.nodeId) { + final startPosition = start.nodePosition; + final endPosition = end.nodePosition; + if (startPosition is TextNodePosition && endPosition is TextNodePosition) { + if (startPosition.offset == endPosition.offset) { + return "[Range] - ${start.nodeId}: ${endPosition.offset}"; + } + + return "[Range] - ${start.nodeId}: [${startPosition.offset}, ${endPosition.offset}]"; + } + + return "[Range] - ${start.nodeId}: [${start.nodePosition}, ${end.nodePosition}]"; + } + + return '[Range] - \n start: ($start),\n end: ($end)'; } } diff --git a/super_editor/lib/src/core/editor.dart b/super_editor/lib/src/core/editor.dart index 2be59935e..d117ad6ac 100644 --- a/super_editor/lib/src/core/editor.dart +++ b/super_editor/lib/src/core/editor.dart @@ -1,8 +1,10 @@ import 'dart:math'; import 'package:attributed_text/attributed_text.dart'; +import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; import 'package:super_editor/src/infrastructure/_logging.dart'; import 'package:uuid/uuid.dart'; @@ -53,6 +55,7 @@ class Editor implements RequestDispatcher { Editor({ required Map editables, List? requestHandlers, + this.historyGroupingPolicy = neverMergePolicy, List? reactionPipeline, List? listeners, }) : requestHandlers = requestHandlers ?? [], @@ -73,9 +76,19 @@ class Editor implements RequestDispatcher { /// Service Locator that provides all resources that are relevant for document editing. late final EditContext context; + /// Policies that determine when a new transaction of changes should be combined with the + /// previous transaction, impacting what is undone by undo. + final HistoryGroupingPolicy historyGroupingPolicy; + /// Executes [EditCommand]s and collects a list of changes. late final _DocumentEditorCommandExecutor _commandExecutor; + List get history => List.from(_history); + final _history = []; + + List get future => List.from(_future); + final _future = []; + /// A pipeline of objects that receive change-lists from command execution /// and get the first opportunity to spawn additional commands before the /// change list is dispatched to regular listeners. @@ -120,58 +133,139 @@ class Editor implements RequestDispatcher { /// Tracks the number of request executions that are in the process of running. int _activeCommandCount = 0; + bool _isInTransaction = false; + bool _isImplicitTransaction = false; + CommandTransaction? _transaction; + + /// Whether the editor is currently running reactions for the current transaction. + bool _isReacting = false; + + /// Starts a transaction that runs across multiple calls to [execute], until [endTransaction] + /// is called. + /// + /// Typically, a transaction only includes the [EditRequest]s that are passed to a single + /// call to [execute]. That's useful in the nominal case where editing code knows everything + /// that needs to execute at one time. However, sometimes the later [EditRequest] within a + /// single transaction can't be configured until the editing code inspects the [Document] + /// after some earlier [EditRequest]. In this situation, the editing code needs to be able + /// to run [execute] multiple times while having all [EditRequest]s still considered to be + /// part of the same transaction. + /// + /// Does nothing if a transaction is already in-progress. + void startTransaction() { + if (_isInTransaction) { + return; + } + + editorEditsLog.info("Starting transaction"); + _isInTransaction = true; + _activeChangeList = []; + _transaction = CommandTransaction([], clock.now()); + + _onTransactionStart(); + } + + /// Ends a transaction that was started with a call to [startTransaction]. + /// + /// Does nothing if a transaction is not in-progress. + void endTransaction() { + if (!_isInTransaction) { + return; + } + + if (_transaction!.commands.isNotEmpty) { + if (_history.isEmpty) { + // Add this transaction onto the history stack. + _history.add(_transaction!); + } else { + final mergeChoice = historyGroupingPolicy.shouldMergeLatestTransaction(_transaction!, _history.last); + switch (mergeChoice) { + case TransactionMerge.noOpinion: + case TransactionMerge.doNotMerge: + // Don't alter the transaction history, just add the new transaction to the history. + _history.add(_transaction!); + case TransactionMerge.mergeOnTop: + // Merge this transaction with the transaction just before it. This is used, for example, + // to group repeated text input into a single undoable transaction. + _history.last + ..commands.addAll(_transaction!.commands) + ..changes.addAll(_transaction!.changes) + ..lastChangeTime = clock.now(); + case TransactionMerge.replacePrevious: + // Replaces the most recent transaction with the new transaction. This is used, for example, + // to throw away unnecessary history about selection and composing region changes, for which + // only the most recent value is relevant. + _history + ..removeLast() + ..add(_transaction!); + } + } + } + + // Now that an atomic set of changes have completed, let the reactions followup + // with more changes, such as auto-correction, tagging, etc. + _reactToChanges(); + + _isInTransaction = false; + _isImplicitTransaction = false; + _transaction = null; + + // Note: The transaction isn't fully considered over until after the reactions run. + // This is because the reactions need access to the change list from the previous + // transaction. + _onTransactionEnd(); + + editorEditsLog.info("Finished transaction"); + } + /// Executes the given [requests]. /// /// Any changes that result from the given [requests] are reported to listeners as a series /// of [EditEvent]s. @override void execute(List requests) { - // print("Request execution:"); - // for (final request in requests) { - // print(" - ${request.runtimeType}"); - // } - // print(StackTrace.current); - - if (_activeCommandCount == 0) { - // This is the start of a new transaction. - for (final editable in context._resources.values) { - editable.onTransactionStart(); - } + if (requests.isEmpty) { + // No changes were requested. Don't waste time starting and ending transactions, etc. + editorEditsLog.warning("Tried to execute requests without providing any requests"); + return; + } + + editorEditsLog.finer("Executing requests:"); + for (final request in requests) { + editorEditsLog.finer(" - ${request.runtimeType}"); + } + + if (_activeCommandCount == 0 && !_isInTransaction) { + // No transaction was explicitly requested, but all changes exist in a transaction. + // Automatically start one, and then end the transaction after the current changes. + _isImplicitTransaction = true; + startTransaction(); } - _activeChangeList ??= []; _activeCommandCount += 1; + final undoableCommands = []; for (final request in requests) { // Execute the given request. final command = _findCommandForRequest(request); final commandChanges = _executeCommand(command); _activeChangeList!.addAll(commandChanges); - } - // Run reactions and notify listeners, but only do it once per batch of executions. - // If we reacted and notified listeners on every execution, then every sub-request - // would also run the reactions and notify listeners. At best this would result in - // many superfluous calls, but in practice it would probably break lots of features - // by notifying listeners too early, and running the same reactions over and over. - if (_activeCommandCount == 1) { - if (_activeChangeList!.isNotEmpty) { - // Run all reactions. These reactions will likely call `execute()` again, with - // their own requests, to make additional changes. - _reactToChanges(); + if (command.historyBehavior == HistoryBehavior.undoable) { + undoableCommands.add(command); + _transaction!.changes.addAll(List.from(commandChanges)); + } + } - // Notify all listeners that care about changes, but won't spawn additional requests. - _notifyListeners(); + // Log the time at the end of the actions in this transaction. + _transaction!.lastChangeTime = clock.now(); - // This is the end of a transaction. - for (final editable in context._resources.values) { - editable.onTransactionEnd(_activeChangeList!); - } - } else { - editorOpsLog.warning("We have an empty change list after processing one or more requests: $requests"); - } + if (undoableCommands.isNotEmpty) { + _transaction!.commands.addAll(undoableCommands); + } - _activeChangeList = null; + if (_activeCommandCount == 1 && _isImplicitTransaction && !_isReacting) { + endTransaction(); } _activeCommandCount -= 1; @@ -211,16 +305,149 @@ class Editor implements RequestDispatcher { return changeList; } + void _onTransactionStart() { + for (final editable in context._resources.values) { + editable.onTransactionStart(); + } + } + + void _onTransactionEnd() { + for (final editable in context._resources.values) { + editable.onTransactionEnd(_activeChangeList!); + } + + _activeChangeList = null; + } + void _reactToChanges() { + if (_activeChangeList!.isEmpty) { + return; + } + + _isReacting = true; + + // First, let reactions modify the content of the active transaction. + for (final reaction in reactionPipeline) { + // Note: we pass the active change list because reactions will cause more + // changes to be added to that list. + reaction.modifyContent(context, this, _activeChangeList!); + } + + // Second, start a new transaction and let reactions add separate changes. + // ignore: prefer_const_constructors + _transaction = CommandTransaction([], clock.now()); for (final reaction in reactionPipeline) { // Note: we pass the active change list because reactions will cause more // changes to be added to that list. reaction.react(context, this, _activeChangeList!); } + + if (_transaction!.commands.isNotEmpty) { + _history.add(_transaction!); + } + + // FIXME: try removing this notify listeners + // Notify all listeners that care about changes, but won't spawn additional requests. + _notifyListeners(List.from(_activeChangeList!, growable: false)); + + _isReacting = false; + } + + void undo() { + editorEditsLog.info("Running undo"); + if (_history.isEmpty) { + return; + } + + editorEditsLog.finer("History before undo:"); + for (final transaction in _history) { + editorEditsLog.finer(" - transaction"); + for (final command in transaction.commands) { + editorEditsLog.finer(" - ${command.runtimeType}: ${command.describe()}"); + } + } + editorEditsLog.finer("---"); + + // Move the latest command from the history to the future. + final transactionToUndo = _history.removeLast(); + _future.add(transactionToUndo); + editorEditsLog.finer("The commands being undone are:"); + for (final command in transactionToUndo.commands) { + editorEditsLog.finer(" - ${command.runtimeType}: ${command.describe()}"); + } + + editorEditsLog.finer("Resetting all editables to their last checkpoint..."); + for (final editable in context._resources.values) { + // Don't let editables notify listeners during undo. + editable.onTransactionStart(); + + // Revert all editables to the last snapshot. + editable.reset(); + } + + // Replay all history except for the most recent command transaction. + editorEditsLog.finer("Replaying all command history except for the most recent transaction..."); + final changeEvents = []; + for (final commandTransaction in _history) { + for (final command in commandTransaction.commands) { + editorEditsLog.finer("Executing command: ${command.runtimeType}"); + // We re-run the commands without tracking changes and without running reactions + // because any relevant reactions should have run the first time around, and already + // submitted their commands. + final commandChanges = _executeCommand(command); + changeEvents.addAll(commandChanges); + } + } + + editorEditsLog.info("Finished undo"); + + editorEditsLog.finer("Ending transaction on all editables"); + for (final editable in context._resources.values) { + // Let editables start notifying listeners again. + editable.onTransactionEnd(changeEvents); + } + + // TODO: find out why this is needed. If it's not, remove it. + _notifyListeners([]); + } + + void redo() { + editorEditsLog.info("Running redo"); + if (_future.isEmpty) { + return; + } + + editorEditsLog.finer("Future transaction:"); + for (final command in _future.last.commands) { + editorEditsLog.finer(" - ${command.runtimeType}"); + } + + for (final editable in context._resources.values) { + // Don't let editables notify listeners during redo. + editable.onTransactionStart(); + } + + final commandTransaction = _future.removeLast(); + final edits = []; + for (final command in commandTransaction.commands) { + final commandEdits = _executeCommand(command); + edits.addAll(commandEdits); + } + _history.add(commandTransaction); + + editorEditsLog.info("Finished redo"); + + editorEditsLog.finer("Ending transaction on all editables"); + for (final editable in context._resources.values) { + // Let editables start notifying listeners again. + editable.onTransactionEnd(edits); + } + + // TODO: find out why this is needed. If it's not, remove it. + _notifyListeners([]); } - void _notifyListeners() { - final changeList = List.from(_activeChangeList!, growable: false); + void _notifyListeners(List changeList) { for (final listener in _changeListeners) { // Note: we pass a given copy of the change list, because listeners should // never cause additional editor changes. @@ -229,6 +456,204 @@ class Editor implements RequestDispatcher { } } +/// The merge policies that are used in the standard [Editor] construction. +const defaultMergePolicy = HistoryGroupingPolicyList( + [ + mergeRepeatSelectionChangesPolicy, + mergeRapidTextInputPolicy, + ], +); + +abstract interface class HistoryGroupingPolicy { + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, + CommandTransaction previousTransaction, + ); +} + +enum TransactionMerge { + noOpinion, + doNotMerge, + mergeOnTop, + replacePrevious; + + static TransactionMerge chooseMoreConservative(TransactionMerge a, TransactionMerge b) { + if (a == b) { + // They're the same. It doesn't matter. + return a; + } + + switch (a) { + case TransactionMerge.noOpinion: + // No opinion has no particular conservative vs liberal metric. Return the other one. + return b; + case TransactionMerge.doNotMerge: + // Explicitly not merging is the most conservative. Return this one. + return a; + case TransactionMerge.mergeOnTop: + if (b == TransactionMerge.doNotMerge) { + // doNotMerge is the only more conservative choice than merging on top. + return b; + } + + return a; + case TransactionMerge.replacePrevious: + if (b == TransactionMerge.noOpinion) { + return a; + } + + // replacePrevious is the lease conservative option. The other one always wins. + return b; + } + } +} + +/// A [HistoryGroupingPolicy] that defers to a list of other individual policies. +/// +/// For most applications, an [Editor]'s transaction grouping policy should probably be +/// a [HistoryGroupingPolicyList] because most applications will want a number of different +/// heuristics that decide when to merge transactions. +/// +/// You can change the way the list of policies make a decision by way of the [choice] +/// property. You can either merge transactions when *any* of the policies want to merge +/// ([HistoryGroupingPolicyListChoice.anyPass]), or you can merge transactions when *all* +/// of the policies want to merge ([HistoryGroupingPolicyListChoice.allPass]). +class HistoryGroupingPolicyList implements HistoryGroupingPolicy { + const HistoryGroupingPolicyList(this.policies); + + final List policies; + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, + CommandTransaction previousTransaction, + ) { + TransactionMerge mostConservativeChoice = TransactionMerge.noOpinion; + + for (final policy in policies) { + final newMergeChoice = policy.shouldMergeLatestTransaction(newTransaction, previousTransaction); + if (newMergeChoice == TransactionMerge.doNotMerge) { + // A policy has explicitly requested not to merge. Don't merge. + return TransactionMerge.doNotMerge; + } + + mostConservativeChoice = TransactionMerge.chooseMoreConservative(mostConservativeChoice, newMergeChoice); + } + + return mostConservativeChoice; + } +} + +const neverMergePolicy = _NeverMergePolicy(); + +class _NeverMergePolicy implements HistoryGroupingPolicy { + const _NeverMergePolicy(); + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) => + TransactionMerge.doNotMerge; +} + +const mergeRepeatSelectionChangesPolicy = MergeRepeatSelectionChangesPolicy(); + +class MergeRepeatSelectionChangesPolicy implements HistoryGroupingPolicy { + const MergeRepeatSelectionChangesPolicy(); + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) { + final isNewTransactionAllSelectionAndComposing = newTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .isEmpty; + + if (!isNewTransactionAllSelectionAndComposing) { + // The new transaction contains meaningful changes. Let other policies decide + // what to do. + return TransactionMerge.noOpinion; + } + + final isPreviousTransactionAllSelectionAndComposing = previousTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .isEmpty; + + if (!isPreviousTransactionAllSelectionAndComposing) { + // The previous transaction contains meaningful changes. Add the new selection/composing + // changes on top so that they're undone with the previous content change. + return TransactionMerge.mergeOnTop; + } + + // The previous and new transactions are all selection and composing changes. We don't + // care about this history. Replaces the previous transaction with the new transaction. + return TransactionMerge.replacePrevious; + } +} + +/// A sane default configuration of a [MergeRapidTextInputPolicy]. +/// +/// To customize the merge time, create a [MergeRapidTextInputPolicy] with the desired merge time. +const mergeRapidTextInputPolicy = MergeRapidTextInputPolicy(); + +class MergeRapidTextInputPolicy implements HistoryGroupingPolicy { + const MergeRapidTextInputPolicy([this._maxMergeTime = const Duration(milliseconds: 100)]); + + final Duration _maxMergeTime; + + @override + TransactionMerge shouldMergeLatestTransaction( + CommandTransaction newTransaction, CommandTransaction previousTransaction) { + final newContentEvents = newTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .toList(); + if (newContentEvents.isEmpty) { + return TransactionMerge.noOpinion; + } + final newTextInsertionEvents = + newContentEvents.where((change) => change is DocumentEdit && change.change is TextInsertionEvent).toList(); + if (newTextInsertionEvents.length != newContentEvents.length) { + // There were 1+ new content changes that weren't text input. Don't merge transactions. + return TransactionMerge.noOpinion; + } + + // At this point we know that all new content changes were text input. + + // Check that the previous transaction was also all text input. + final previousContentEvents = previousTransaction.changes + .where((change) => change is! SelectionChangeEvent && change is! ComposingRegionChangeEvent) + .toList(); + if (previousContentEvents.isEmpty) { + return TransactionMerge.noOpinion; + } + final previousTextInsertionEvents = + previousContentEvents.where((change) => change is DocumentEdit && change.change is TextInsertionEvent).toList(); + if (previousTextInsertionEvents.length != previousContentEvents.length) { + // There were 1+ new content changes that weren't text input. Don't merge transactions. + return TransactionMerge.noOpinion; + } + + if (newTransaction.firstChangeTime.difference(previousTransaction.lastChangeTime!) > _maxMergeTime) { + // The text insertions were far enough apart in time that we don't want to merge them. + return TransactionMerge.noOpinion; + } + + // The new and previous transactions were entirely text input. They happened quickly. + // Merge them together. + return TransactionMerge.mergeOnTop; + } +} + +class CommandTransaction { + CommandTransaction(this.commands, this.firstChangeTime) + : changes = [], + lastChangeTime = firstChangeTime; + + final List commands; + final List changes; + + final DateTime firstChangeTime; + DateTime lastChangeTime; +} + /// An implementation of [CommandExecutor], designed for [Editor]. class _DocumentEditorCommandExecutor implements CommandExecutor { _DocumentEditorCommandExecutor(this._context); @@ -284,6 +709,19 @@ abstract mixin class Editable { /// A transaction that was previously started with [onTransactionStart] has now ended, this /// [Editable] should notify interested parties of changes. void onTransactionEnd(List edits) {} + + // /// Creates and returns a snapshot of this [Editable]'s current state, or `null` if + // /// this [Editable] is in its initial state. + // /// + // /// The returned snapshot must be a deep copy of any relevant information. It must not + // /// hold any references to data outside the snapshot. + // Object? createSnapshot(); + // + // /// Updates the state of this [Editable] to match the given [snapshot]. + // void restoreSnapshot(Object snapshot); + + /// Resets this [Editable] to its initial state. + void reset() {} } /// An object that processes [EditRequest]s. @@ -294,8 +732,29 @@ abstract class RequestDispatcher { /// A command that alters something in a [Editor]. abstract class EditCommand { + const EditCommand(); + /// Executes this command and logs all changes with the [executor]. void execute(EditContext context, CommandExecutor executor); + + /// The desired "undo" behavior of this command. + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + String describe() => toString(); +} + +/// The way a command interacts with the history ledger, AKA "undo". +enum HistoryBehavior { + /// The command can be undone and redone. + /// + /// For example: inserting text into a paragraph. + undoable, + + /// The command has no impact on history. + /// + /// For example: entering and exiting interaction mode, (possibly) activating and + /// deactivating bold/italics in the composer. + nonHistorical, } /// All resources that are available when executing [EditCommand]s, such as a document, @@ -428,17 +887,23 @@ abstract class EditRequest { /// A change that took place within a [Editor]. abstract class EditEvent { - // Marker interface for all editor change events. + const EditEvent(); + + /// Describes this change in a human-readable way. + String describe() => toString(); } /// An [EditEvent] that altered a [Document]. /// /// The specific [Document] change is available in [change]. -class DocumentEdit implements EditEvent { +class DocumentEdit extends EditEvent { DocumentEdit(this.change); final DocumentChange change; + @override + String describe() => change.describe(); + @override String toString() => "DocumentEdit -> $change"; @@ -450,27 +915,57 @@ class DocumentEdit implements EditEvent { int get hashCode => change.hashCode; } -/// An object that's notified with a change list from one or more -/// commands that were just executed. +/// An object that's notified with a change list from one or more commands that were just +/// executed. /// -/// An [EditReaction] can use the given [executor] to spawn additional -/// [EditCommand]s that should run in response to the [changeList]. +/// An [EditReaction] can use the given [reactionExecutor] to spawn additional [EditCommand]s +/// that should run in response to the [changeList]. abstract class EditReaction { - void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList); + const EditReaction(); + + /// Executes additional [modifications] within the current editor transaction. + /// + /// If undo is run, the recent changes AND the [modifications] will be undone, together. + /// This is useful, for example, for a reaction such as spell-check, whose reaction is + /// tied directly to the content and shouldn't stand on its own. + /// + /// To execute actions that are undone on their own, use [react]. + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) {} + + /// Executes additional [actions] in a new standalone transaction. + /// + /// If undo is run, these changes will be undone, but the changes leading up to this + /// call to [react] will not be undone by that undo call. + /// + /// To execute additional actions that are undone at the same time as the preceding + /// changes, use [modifyContent]. + void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) {} } /// An [EditReaction] that delegates its reaction to a given callback function. -class FunctionalEditReaction implements EditReaction { - FunctionalEditReaction(this._react); +class FunctionalEditReaction extends EditReaction { + FunctionalEditReaction({ + Reaction? modifyContent, + Reaction? react, + }) : _modifyContent = modifyContent, + _react = react, + assert(modifyContent != null || react != null); - final void Function(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) - _react; + final Reaction? _modifyContent; + final Reaction? _react; + + @override + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) => + _modifyContent?.call(editorContext, requestDispatcher, changeList); @override void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) => - _react(editorContext, requestDispatcher, changeList); + _react?.call(editorContext, requestDispatcher, changeList); } +typedef Reaction = void Function( + EditContext editorContext, RequestDispatcher requestDispatcher, List changeList); + /// An object that's notified with a change list from one or more /// commands that were just executed within a [Editor]. /// @@ -503,6 +998,8 @@ class MutableDocument implements Document, Editable { List? nodes, }) : _nodes = nodes ?? [] { _refreshNodeIdCaches(); + + _latestNodesSnapshot = _nodes.map((node) => node.copy()).toList(); } /// Creates an [Document] with a single [ParagraphNode]. @@ -523,6 +1020,9 @@ class MutableDocument implements Document, Editable { _listeners.clear(); } + late final List _latestNodesSnapshot; + bool _didReset = false; + final List _nodes; @override @@ -738,9 +1238,10 @@ class MutableDocument implements Document, Editable { @override void onTransactionEnd(List edits) { final documentChanges = edits.whereType().map((edit) => edit.change).toList(); - if (documentChanges.isEmpty) { + if (documentChanges.isEmpty && !_didReset) { return; } + _didReset = false; final changeLog = DocumentChangeLog(documentChanges); for (final listener in _listeners) { @@ -748,6 +1249,16 @@ class MutableDocument implements Document, Editable { } } + @override + void reset() { + _nodes + ..clear() + ..addAll(_latestNodesSnapshot.map((node) => node.copy()).toList()); + _refreshNodeIdCaches(); + + _didReset = true; + } + /// Updates all the maps which use the node id as the key. /// /// All the maps are cleared and re-populated. diff --git a/super_editor/lib/src/default_editor/blockquote.dart b/super_editor/lib/src/default_editor/blockquote.dart index 4a19e103f..694758fcc 100644 --- a/super_editor/lib/src/default_editor/blockquote.dart +++ b/super_editor/lib/src/default_editor/blockquote.dart @@ -238,13 +238,16 @@ class BlockquoteComponent extends StatelessWidget { } } -class ConvertBlockquoteToParagraphCommand implements EditCommand { +class ConvertBlockquoteToParagraphCommand extends EditCommand { ConvertBlockquoteToParagraphCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -324,7 +327,7 @@ ExecutionInstruction splitBlockquoteWhenEnterPressed({ return didSplit ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -class SplitBlockquoteCommand implements EditCommand { +class SplitBlockquoteCommand extends EditCommand { SplitBlockquoteCommand({ required this.nodeId, required this.splitPosition, @@ -335,6 +338,9 @@ class SplitBlockquoteCommand implements EditCommand { final TextPosition splitPosition; final String newNodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); diff --git a/super_editor/lib/src/default_editor/box_component.dart b/super_editor/lib/src/default_editor/box_component.dart index c9784b256..4bfd4572f 100644 --- a/super_editor/lib/src/default_editor/box_component.dart +++ b/super_editor/lib/src/default_editor/box_component.dart @@ -310,11 +310,14 @@ class SelectableBox extends StatelessWidget { } } -class DeleteUpstreamAtBeginningOfBlockNodeCommand implements EditCommand { +class DeleteUpstreamAtBeginningOfBlockNodeCommand extends EditCommand { DeleteUpstreamAtBeginningOfBlockNodeCommand(this.node); final DocumentNode node; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); diff --git a/super_editor/lib/src/default_editor/common_editor_operations.dart b/super_editor/lib/src/default_editor/common_editor_operations.dart index 0f7e0b420..5cc7d2f4b 100644 --- a/super_editor/lib/src/default_editor/common_editor_operations.dart +++ b/super_editor/lib/src/default_editor/common_editor_operations.dart @@ -1131,7 +1131,6 @@ class CommonEditorOperations { } else { editor.execute([const DeleteUpstreamCharacterRequest()]); return true; - // return _deleteUpstreamCharacter(); } } @@ -2193,6 +2192,10 @@ class CommonEditorOperations { void paste() { DocumentPosition pastePosition = composer.selection!.extent; + // Start a transaction so that we can capture both the initial deletion behavior, + // and the clipboard content insertion, all as one transaction. + editor.startTransaction(); + // Delete all currently selected content. if (!composer.selection!.isCollapsed) { pastePosition = CommonEditorOperations.getDocumentPositionAfterExpandedDeletion( @@ -2219,6 +2222,8 @@ class CommonEditorOperations { composer: composer, pastePosition: pastePosition, ); + + editor.endTransaction(); } Future _paste({ @@ -2233,7 +2238,6 @@ class CommonEditorOperations { PasteEditorRequest( content: content, pastePosition: pastePosition, - composer: composer, ), ]); } @@ -2243,30 +2247,29 @@ class PasteEditorRequest implements EditRequest { PasteEditorRequest({ required this.content, required this.pastePosition, - required this.composer, }); final String content; final DocumentPosition pastePosition; - final DocumentComposer composer; } -class PasteEditorCommand implements EditCommand { +class PasteEditorCommand extends EditCommand { PasteEditorCommand({ required String content, required DocumentPosition pastePosition, - required DocumentComposer composer, }) : _content = content, - _pastePosition = pastePosition, - _composer = composer; + _pastePosition = pastePosition; final String _content; final DocumentPosition _pastePosition; - final DocumentComposer _composer; + + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); + final composer = context.find(Editor.composerKey); final currentNodeWithSelection = document.getNodeById(_pastePosition.nodeId); if (currentNodeWithSelection is! TextNode) { throw Exception('Can\'t handle pasting text within node of type: $currentNodeWithSelection'); @@ -2345,7 +2348,7 @@ class PasteEditorCommand implements EditCommand { SelectionReason.userInteraction, ), ); - editorOpsLog.fine('New selection after paste operation: ${_composer.selection}'); + editorOpsLog.fine('New selection after paste operation: ${composer.selection}'); editorOpsLog.fine('Done with paste command.'); } @@ -2437,9 +2440,12 @@ class DeleteUpstreamCharacterRequest implements EditRequest { const DeleteUpstreamCharacterRequest(); } -class DeleteUpstreamCharacterCommand implements EditCommand { +class DeleteUpstreamCharacterCommand extends EditCommand { const DeleteUpstreamCharacterCommand(); + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -2488,9 +2494,12 @@ class DeleteDownstreamCharacterRequest implements EditRequest { const DeleteDownstreamCharacterRequest(); } -class DeleteDownstreamCharacterCommand implements EditCommand { +class DeleteDownstreamCharacterCommand extends EditCommand { const DeleteDownstreamCharacterCommand(); + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); diff --git a/super_editor/lib/src/default_editor/composer/composer_reactions.dart b/super_editor/lib/src/default_editor/composer/composer_reactions.dart index a868f03da..f8cc82e8a 100644 --- a/super_editor/lib/src/default_editor/composer/composer_reactions.dart +++ b/super_editor/lib/src/default_editor/composer/composer_reactions.dart @@ -37,7 +37,7 @@ import 'package:super_editor/src/default_editor/text.dart'; /// Conversely, if the caret moves due to the user typing a character, or /// if the selection is expanded, then this reaction doesn't activate any /// styles. -class UpdateComposerTextStylesReaction implements EditReaction { +class UpdateComposerTextStylesReaction extends EditReaction { UpdateComposerTextStylesReaction({ Set? stylesToExtend, }) : _stylesToExtend = stylesToExtend ?? defaultExtendableStyles; 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 d0ecd9d86..316ecd90f 100644 --- a/super_editor/lib/src/default_editor/default_document_editor.dart +++ b/super_editor/lib/src/default_editor/default_document_editor.dart @@ -14,6 +14,7 @@ import 'default_document_editor_reactions.dart'; Editor createDefaultDocumentEditor({ required MutableDocument document, required MutableDocumentComposer composer, + HistoryGroupingPolicy historyGroupingPolicy = defaultMergePolicy, }) { final editor = Editor( editables: { @@ -21,6 +22,7 @@ Editor createDefaultDocumentEditor({ Editor.composerKey: composer, }, requestHandlers: List.from(defaultRequestHandlers), + historyGroupingPolicy: historyGroupingPolicy, reactionPipeline: List.from(defaultEditorReactions), ); @@ -52,6 +54,9 @@ final defaultRequestHandlers = List.unmodifiable([ (request) => request is ChangeInteractionModeRequest // ? ChangeInteractionModeCommand(isInteractionModeDesired: request.isInteractionModeDesired) : null, + (request) => request is RemoveComposerPreferenceStylesRequest // + ? RemoveComposerPreferenceStylesCommand(request.stylesToRemove) + : null, (request) => request is InsertTextRequest ? InsertTextCommand( documentPosition: request.documentPosition, @@ -247,7 +252,6 @@ final defaultRequestHandlers = List.unmodifiable([ ? PasteEditorCommand( content: request.content, pastePosition: request.pastePosition, - composer: request.composer, ) : null, ]); diff --git a/super_editor/lib/src/default_editor/default_document_editor_reactions.dart b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart index af51bf3df..1d7654c9b 100644 --- a/super_editor/lib/src/default_editor/default_document_editor_reactions.dart +++ b/super_editor/lib/src/default_editor/default_document_editor_reactions.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:attributed_text/attributed_text.dart'; import 'package:characters/characters.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:linkify/linkify.dart'; @@ -281,7 +282,7 @@ class BlockquoteConversionReaction extends ParagraphPrefixConversionReaction { /// the node's text is kept. /// /// Applied only to all [TextNode]s. -class HorizontalRuleConversionReaction implements EditReaction { +class HorizontalRuleConversionReaction extends EditReaction { // Matches "---" or "—-" (an em-dash followed by a regular dash) at the beginning of a line, // followed by a space. static final _hrPattern = RegExp(r'^(---|—-)\s'); @@ -304,7 +305,8 @@ class HorizontalRuleConversionReaction implements EditReaction { return; } - final edit = changeList[changeList.length - 2] as DocumentEdit; + // final edit = changeList[changeList.length - 2] as DocumentEdit; + final edit = changeList.reversed.firstWhere((edit) => edit is DocumentEdit) as DocumentEdit; if (edit.change is! TextInsertionEvent) { // This reaction requires that the two last events are an insertion event // followed by a selection change event. @@ -352,7 +354,7 @@ class HorizontalRuleConversionReaction implements EditReaction { /// Base class for [EditReaction]s that want to take action when the user types text at /// the beginning of a paragraph, which matches a given [RegExp]. -abstract class ParagraphPrefixConversionReaction implements EditReaction { +abstract class ParagraphPrefixConversionReaction extends EditReaction { const ParagraphPrefixConversionReaction({ bool requireSpaceInsertion = true, }) : _requireSpaceInsertion = requireSpaceInsertion; @@ -371,17 +373,19 @@ abstract class ParagraphPrefixConversionReaction implements EditReaction { @override void react(EditContext editContext, RequestDispatcher requestDispatcher, List changeList) { final document = editContext.find(Editor.documentKey); - final didTypeSpaceAtEnd = EditInspector.didTypeSpaceAtEndOfNode(document, changeList); - if (_requireSpaceInsertion && !didTypeSpaceAtEnd) { + final typedText = EditInspector.findLastTextUserTyped(document, changeList); + if (typedText == null) { + return; + } + if (_requireSpaceInsertion && !typedText.text.text.endsWith(" ")) { return; } - final edit = changeList[changeList.length - 2] as DocumentEdit; - final textInsertionEvent = edit.change as TextInsertionEvent; - final paragraph = document.getNodeById(textInsertionEvent.nodeId); + final paragraph = document.getNodeById(typedText.nodeId); if (paragraph is! ParagraphNode) { return; } + final match = pattern.firstMatch(paragraph.text.text)?.group(0); if (match == null) { return; @@ -406,7 +410,7 @@ abstract class ParagraphPrefixConversionReaction implements EditReaction { /// When the user creates a new node, and the previous node is just a URL /// to an image, the replaces the previous node with the referenced image. -class ImageUrlConversionReaction implements EditReaction { +class ImageUrlConversionReaction extends EditReaction { const ImageUrlConversionReaction(); @override @@ -549,7 +553,7 @@ class ImageUrlConversionReaction implements EditReaction { /// A plain text URL only has a link applied to it when the user enters a space " " /// after a token that looks like a URL. If the user doesn't enter a trailing space, /// or the preceding token doesn't look like a URL, then the link attribution isn't aplied. -class LinkifyReaction implements EditReaction { +class LinkifyReaction extends EditReaction { const LinkifyReaction({ this.updatePolicy = LinkUpdatePolicy.preserve, }); @@ -638,7 +642,7 @@ class LinkifyReaction implements EditReaction { if (!didInsertSpace) { // We didn't linkify any text. Check if we need to update an URL. - _tryUpdateLinkAttribution(document, composer, edits); + _tryUpdateLinkAttribution(requestDispatcher, document, composer, edits); } } @@ -718,7 +722,8 @@ class LinkifyReaction implements EditReaction { } /// Update or remove the link attributions if edits happen at the middle of a link. - void _tryUpdateLinkAttribution(Document document, MutableDocumentComposer composer, List changeList) { + void _tryUpdateLinkAttribution(RequestDispatcher requestDispatcher, Document document, + MutableDocumentComposer composer, List changeList) { if (!const [LinkUpdatePolicy.remove, LinkUpdatePolicy.update].contains(updatePolicy)) { // We are configured to NOT change the attributions. Fizzle. return; @@ -743,21 +748,20 @@ class LinkifyReaction implements EditReaction { insertionOrDeletionEvent = editEvent.change as NodeChangeEvent; } else { - final selectionEvent = changeList.last; - if (selectionEvent is! SelectionChangeEvent) { - // The last event isn't a selection event. We expect a URL change + final lastSelectionEventIndex = changeList.lastIndexWhere((change) => change is SelectionChangeEvent); + if (lastSelectionEventIndex < 1) { + // There's no selection change event. We expect a URL change // to consist of an insertion or a deletion followed by a selection // change. This event list doesn't fit the pattern. Fizzle. return; } - final edit = changeList[changeList.length - 2]; + final edit = changeList[lastSelectionEventIndex - 1]; if (edit is! DocumentEdit || // (edit.change is! TextInsertionEvent && edit.change is! TextDeletedEvent)) { - // The second to last event isn't an insertion or deletion. We - // expect a URL change to consist of an insertion or a deletion - // followed by a selection change. This event list doesn't fit - // the pattern. Fizzle. + // The event before the selection change isn't an insertion or deletion. We + // expect a URL change to consist of an insertion or a deletion followed by + // a selection change. This event list doesn't fit the pattern. Fizzle. return; } @@ -829,10 +833,24 @@ class LinkifyReaction implements EditReaction { attributionFilter: (attr) => attr is LinkAttribution, range: rangeToUpdate, ); - for (final attributionSpan in attributionsToRemove) { - changedNodeText.removeAttribution(attributionSpan.attribution, attributionSpan.range); - composer.preferences.removeStyle(attributionSpan.attribution); - } + + final linkRange = DocumentRange( + start: DocumentPosition( + nodeId: changedNodeId, + nodePosition: TextNodePosition(offset: rangeToUpdate.start), + ), + end: DocumentPosition( + nodeId: changedNodeId, + nodePosition: TextNodePosition(offset: rangeToUpdate.end + 1), + ), + ); + + final linkChangeRequests = [ + RemoveTextAttributionsRequest( + documentRange: linkRange, + attributions: {attributionsToRemove.first.attribution}, + ), + ]; // A URL was changed and we have now removed the original link. Removing // the original link was a necessary step for both `LinkUpdatePolicy.remove` @@ -841,13 +859,30 @@ class LinkifyReaction implements EditReaction { // If the policy is `LinkUpdatePolicy.update` then we need to add a new // link attribution that reflects the edited URL text. We do that below. if (updatePolicy == LinkUpdatePolicy.update) { - changedNodeText.addAttribution( - LinkAttribution.fromUri( - parseLink(changedNodeText.text.substring(rangeToUpdate.start, rangeToUpdate.end + 1)), + linkChangeRequests.add( + // Switch out the old link attribution for the new one. + AddTextAttributionsRequest( + documentRange: linkRange, + attributions: { + LinkAttribution.fromUri( + parseLink(changedNodeText.text.substring(rangeToUpdate.start, rangeToUpdate.end + 1)), + ) + }, ), - rangeToUpdate, ); } + + linkChangeRequests.add( + // When the caret is in the middle of a link then the composer will automatically + // apply that style to the next character. Remove the current link style + // from the composer's preferences, so that as the user types, he doesn't + // immediately add the link attribution we just deleted. + RemoveComposerPreferenceStylesRequest( + attributionsToRemove.map((span) => span.attribution).toSet(), + ), + ); + + requestDispatcher.execute(linkChangeRequests); } } @@ -883,7 +918,7 @@ enum LinkUpdatePolicy { /// dash are removed and an em-dash (—) is inserted. /// /// This reaction applies to all [TextNode]s in the document. -class DashConversionReaction implements EditReaction { +class DashConversionReaction extends EditReaction { const DashConversionReaction(); @override @@ -898,40 +933,36 @@ class DashConversionReaction implements EditReaction { return; } - final selectionEvent = changeList.last; - if (selectionEvent is! SelectionChangeEvent) { - // This reaction requires that the two last events are an insertion event - // followed by a selection change event. - // The last event isn't a selection event, therefore this reaction - // shouldn't apply. Fizzle. - return; - } - - final documentEdit = changeList[changeList.length - 2]; - if (documentEdit is! DocumentEdit || documentEdit.change is! TextInsertionEvent) { - // This reaction requires that the two last events are an insertion event - // followed by a selection change event. - // The second to last event isn't a text insertion event, therefore this reaction - // shouldn't apply. Fizzle. - return; - } + TextInsertionEvent? dashInsertionEvent; + for (final event in changeList) { + if (event is! DocumentEdit) { + continue; + } - final insertionEvent = documentEdit.change as TextInsertionEvent; + final change = event.change; + if (change is! TextInsertionEvent) { + continue; + } + if (change.text.text != "-") { + continue; + } - if (insertionEvent.text.text != '-') { - // The text that was inserted wasn't a dash. The only character that triggers a - // conversion is a dash. Fizzle. + dashInsertionEvent = change; + break; + } + if (dashInsertionEvent == null) { + // The user didn't type a dash. return; } - if (insertionEvent.offset < 1) { - // The reaction needs at least two characters before the caret, but there's less than two. Fizzle. + if (dashInsertionEvent.offset == 0) { + // There's nothing upstream from this dash, therefore it can't + // be a 2nd dash. return; } - final insertionNode = document.getNodeById(insertionEvent.nodeId) as TextNode; - - final upstreamCharacter = insertionNode.text.text[insertionEvent.offset - 1]; + final insertionNode = document.getNodeById(dashInsertionEvent.nodeId) as TextNode; + final upstreamCharacter = insertionNode.text.text[dashInsertionEvent.offset - 1]; if (upstreamCharacter != '-') { return; } @@ -942,16 +973,16 @@ class DashConversionReaction implements EditReaction { DeleteContentRequest( documentRange: DocumentRange( start: DocumentPosition( - nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: insertionEvent.offset - 1)), + nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset - 1)), end: DocumentPosition( - nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: insertionEvent.offset + 1)), + nodeId: insertionNode.id, nodePosition: TextNodePosition(offset: dashInsertionEvent.offset + 1)), ), ), InsertTextRequest( documentPosition: DocumentPosition( nodeId: insertionNode.id, nodePosition: TextNodePosition( - offset: insertionEvent.offset - 1, + offset: dashInsertionEvent.offset - 1, ), ), textToInsert: SpecialCharacters.emDash, @@ -961,7 +992,7 @@ class DashConversionReaction implements EditReaction { DocumentSelection.collapsed( position: DocumentPosition( nodeId: insertionNode.id, - nodePosition: TextNodePosition(offset: insertionEvent.offset), + nodePosition: TextNodePosition(offset: dashInsertionEvent.offset), ), ), SelectionChangeType.placeCaret, @@ -982,19 +1013,29 @@ class EditInspector { return false; } - // If the user typed a space, then the last event should be a selection change. - final selectionEvent = edits[edits.length - 1]; - if (selectionEvent is! SelectionChangeEvent) { + // If the user typed a space, then the final document edit should be a text + // insertion event with a space " ". + DocumentEdit? lastDocumentEditEvent; + SelectionChangeEvent? lastSelectionChangeEvent; + for (int i = edits.length - 1; i >= 0; i -= 1) { + if (edits[i] is DocumentEdit) { + lastDocumentEditEvent = edits[i] as DocumentEdit; + } else if (lastSelectionChangeEvent == null && edits[i] is SelectionChangeEvent) { + lastSelectionChangeEvent = edits[i] as SelectionChangeEvent; + } + + if (lastDocumentEditEvent != null) { + break; + } + } + if (lastDocumentEditEvent == null) { return false; } - - // If the user typed a space, then the second to last event should be a text - // insertion event with a space " ". - final edit = edits[edits.length - 2]; - if (edit is! DocumentEdit) { + if (lastSelectionChangeEvent == null) { return false; } - final textInsertionEvent = edit.change; + + final textInsertionEvent = lastDocumentEditEvent.change; if (textInsertionEvent is! TextInsertionEvent) { return false; } @@ -1002,7 +1043,7 @@ class EditInspector { return false; } - if (selectionEvent.newSelection!.extent.nodeId != textInsertionEvent.nodeId) { + if (lastSelectionChangeEvent.newSelection!.extent.nodeId != textInsertionEvent.nodeId) { return false; } @@ -1015,58 +1056,62 @@ class EditInspector { return true; } - /// Returns `true` if the given [edits] end with the user typing a space at the end of - /// a [TextNode], e.g., typing a " " at the end of a paragraph. - static bool didTypeSpaceAtEndOfNode(Document document, List edits) { - if (edits.length < 2) { - // This reaction requires at least an insertion event and a selection change event. - // There are less than two events in the the change list, therefore this reaction - // shouldn't apply. Fizzle. - return false; - } - - // If the user typed a space, then the last event should be a selection change. - final selectionEvent = edits[edits.length - 1]; - if (selectionEvent is! SelectionChangeEvent) { - return false; + /// Finds and returns the last text the user typed within the given [edit]s, or `null` if + /// no text was typed. + static UserTypedText? findLastTextUserTyped(Document document, List edits) { + final lastSpaceInsertion = edits.whereType().lastWhereOrNull( + (edit) => edit.change is TextInsertionEvent && (edit.change as TextInsertionEvent).text.text.endsWith(" ")); + if (lastSpaceInsertion == null) { + // The user didn't insert any text segment that ended with a space. + return null; } - // If the user typed a space, then the second to last event should be a text - // insertion event with a space " ". - final edit = edits[edits.length - 2]; - if (edit is! DocumentEdit) { - return false; - } - final textInsertionEvent = edit.change; - if (textInsertionEvent is! TextInsertionEvent) { - return false; - } - if (textInsertionEvent.text.text != " ") { - return false; + final spaceInsertionChangeIndex = edits.indexWhere((edit) => edit == lastSpaceInsertion); + final selectionAfterInsertionIndex = + edits.indexWhere((edit) => edit is SelectionChangeEvent, spaceInsertionChangeIndex); + if (selectionAfterInsertionIndex < 0) { + // The text insertion wasn't followed by a selection change. It's not clear what this + // means, but we can't say with confidence that the user typed the space. Perhaps the + // space was injected by some other means. + return null; } - if (selectionEvent.oldSelection == null || selectionEvent.newSelection == null) { - return false; + final newSelection = (edits[selectionAfterInsertionIndex] as SelectionChangeEvent).newSelection; + if (newSelection == null) { + // There's no selection, which indicates something other than the user typing. + return null; } - if (selectionEvent.newSelection!.extent.nodeId != textInsertionEvent.nodeId) { - return false; + if (!newSelection.isCollapsed) { + // The selection is expanded, which indicates something other than the user typing. + return null; } - final editedNode = document.getNodeById(textInsertionEvent.nodeId)!; - if (editedNode is! TextNode) { - return false; + final textInsertionEvent = lastSpaceInsertion.change as TextInsertionEvent; + if (textInsertionEvent.nodeId != newSelection.extent.nodeId) { + // The selection is in a different node than where tex was inserted. This indicates + // something other than a user typing. + return null; } - final caretPosition = selectionEvent.newSelection!.extent.nodePosition as TextNodePosition; - final editedText = editedNode.text.text; - if (caretPosition.offset != editedText.length) { - return false; + final newCaretOffset = (newSelection.extent.nodePosition as TextNodePosition).offset; + if (textInsertionEvent.offset + textInsertionEvent.text.length != newCaretOffset) { + return null; } - // The inserted text was a space, and the caret now sits at the end of - // the edited text. We assume this means that the user just typed a space. - return true; + return UserTypedText( + textInsertionEvent.nodeId, + textInsertionEvent.offset, + textInsertionEvent.text, + ); } EditInspector._(); } + +class UserTypedText { + const UserTypedText(this.nodeId, this.offset, this.text); + + final String nodeId; + final int offset; + final AttributedText text; +} diff --git a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart index 054176bbe..a37a6ea1d 100644 --- a/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart +++ b/super_editor/lib/src/default_editor/document_ime/document_delta_editing.dart @@ -71,6 +71,10 @@ class TextDeltasDocumentEditor { composing: _serializedDoc.documentToImeRange(_serializedDoc.composingRegion), ); + // Start an editor transaction so that all changes made during this delta + // application is considered a single undo-able change. + editor.startTransaction(); + for (final delta in textEditingDeltas) { editorImeLog.info("---------------------------------------------------"); @@ -101,13 +105,18 @@ class TextDeltasDocumentEditor { DocumentRange? docComposingRegion = _calculateNewComposingRegion(textEditingDeltas); - editor.execute([ - ChangeComposingRegionRequest( - docComposingRegion, - ), - ]); + if (docComposingRegion != composingRegion.value) { + editor.execute([ + ChangeComposingRegionRequest( + docComposingRegion, + ), + ]); + } editorImeLog.fine("Document composing region: ${composingRegion.value}"); + // End the editor transaction for all deltas in this call. + editor.endTransaction(); + _nextImeValue = null; } @@ -273,13 +282,6 @@ class TextDeltasDocumentEditor { editorImeLog .fine("Updating the Document Composer's selection to place caret at insertion offset:\n$insertionSelection"); final selectionBeforeInsertion = selection.value; - editor.execute([ - ChangeSelectionRequest( - insertionSelection, - SelectionChangeType.placeCaret, - SelectionReason.userInteraction, - ), - ]); editorImeLog.fine("Inserting the text at the Document Composer's selection"); final didInsert = _insertPlainText( @@ -331,6 +333,11 @@ class TextDeltasDocumentEditor { editorOpsLog.fine("Executing text insertion command."); editorOpsLog.finer("Text before insertion: '${insertionNode.text.text}'"); editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed(position: insertionPosition), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ), InsertTextRequest( documentPosition: insertionPosition, textToInsert: text, diff --git a/super_editor/lib/src/default_editor/horizontal_rule.dart b/super_editor/lib/src/default_editor/horizontal_rule.dart index 1ca5a0b9d..36a034243 100644 --- a/super_editor/lib/src/default_editor/horizontal_rule.dart +++ b/super_editor/lib/src/default_editor/horizontal_rule.dart @@ -33,6 +33,11 @@ class HorizontalRuleNode extends BlockNode with ChangeNotifier { return other is HorizontalRuleNode; } + @override + HorizontalRuleNode copy() { + return HorizontalRuleNode(id: id); + } + @override bool operator ==(Object other) => identical(this, other) || other is HorizontalRuleNode && runtimeType == other.runtimeType && id == other.id; diff --git a/super_editor/lib/src/default_editor/image.dart b/super_editor/lib/src/default_editor/image.dart index 4caecb8b7..fe6d3f28c 100644 --- a/super_editor/lib/src/default_editor/image.dart +++ b/super_editor/lib/src/default_editor/image.dart @@ -82,6 +82,17 @@ class ImageNode extends BlockNode with ChangeNotifier { return other is ImageNode && imageUrl == other.imageUrl && altText == other.altText; } + @override + ImageNode copy() { + return ImageNode( + id: id, + imageUrl: imageUrl, + expectedBitmapSize: expectedBitmapSize, + altText: altText, + metadata: Map.from(metadata), + ); + } + @override bool operator ==(Object other) => identical(this, other) || diff --git a/super_editor/lib/src/default_editor/list_items.dart b/super_editor/lib/src/default_editor/list_items.dart index 9feb4b593..49a1cfd80 100644 --- a/super_editor/lib/src/default_editor/list_items.dart +++ b/super_editor/lib/src/default_editor/list_items.dart @@ -83,6 +83,17 @@ class ListItemNode extends TextNode { return other is ListItemNode && type == other.type && indent == other.indent && text == other.text; } + @override + ListItemNode copy() { + return ListItemNode( + id: id, + text: text.copyText(0), + itemType: type, + indent: indent, + metadata: Map.from(metadata), + ); + } + @override bool operator ==(Object other) => identical(this, other) || @@ -814,13 +825,16 @@ class IndentListItemRequest implements EditRequest { final String nodeId; } -class IndentListItemCommand implements EditCommand { +class IndentListItemCommand extends EditCommand { IndentListItemCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -849,13 +863,16 @@ class UnIndentListItemRequest implements EditRequest { final String nodeId; } -class UnIndentListItemCommand implements EditCommand { +class UnIndentListItemCommand extends EditCommand { UnIndentListItemCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -891,7 +908,7 @@ class ConvertListItemToParagraphRequest implements EditRequest { final Map? paragraphMetadata; } -class ConvertListItemToParagraphCommand implements EditCommand { +class ConvertListItemToParagraphCommand extends EditCommand { ConvertListItemToParagraphCommand({ required this.nodeId, this.paragraphMetadata, @@ -900,6 +917,9 @@ class ConvertListItemToParagraphCommand implements EditCommand { final String nodeId; final Map? paragraphMetadata; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -935,7 +955,7 @@ class ConvertParagraphToListItemRequest implements EditRequest { final ListItemType type; } -class ConvertParagraphToListItemCommand implements EditCommand { +class ConvertParagraphToListItemCommand extends EditCommand { ConvertParagraphToListItemCommand({ required this.nodeId, required this.type, @@ -944,6 +964,9 @@ class ConvertParagraphToListItemCommand implements EditCommand { final String nodeId; final ListItemType type; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -975,7 +998,7 @@ class ChangeListItemTypeRequest implements EditRequest { final ListItemType newType; } -class ChangeListItemTypeCommand implements EditCommand { +class ChangeListItemTypeCommand extends EditCommand { ChangeListItemTypeCommand({ required this.nodeId, required this.newType, @@ -984,6 +1007,9 @@ class ChangeListItemTypeCommand implements EditCommand { final String nodeId; final ListItemType newType; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -1016,7 +1042,7 @@ class SplitListItemRequest implements EditRequest { final String newNodeId; } -class SplitListItemCommand implements EditCommand { +class SplitListItemCommand extends EditCommand { SplitListItemCommand({ required this.nodeId, required this.splitPosition, @@ -1027,6 +1053,9 @@ class SplitListItemCommand implements EditCommand { final TextPosition splitPosition; final String newNodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); 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 a1a82640f..62271f06a 100644 --- a/super_editor/lib/src/default_editor/multi_node_editing.dart +++ b/super_editor/lib/src/default_editor/multi_node_editing.dart @@ -28,7 +28,7 @@ class PasteStructuredContentEditorRequest implements EditRequest { /// Inserts given structured content, in the form of a `List` of [DocumentNode]s at a /// given paste position within the document. -class PasteStructuredContentEditorCommand implements EditCommand { +class PasteStructuredContentEditorCommand extends EditCommand { PasteStructuredContentEditorCommand({ required List content, required DocumentPosition pastePosition, @@ -38,6 +38,9 @@ class PasteStructuredContentEditorCommand implements EditCommand { final List _content; final DocumentPosition _pastePosition; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { if (_content.isEmpty) { @@ -284,6 +287,9 @@ class InsertNodeAtIndexCommand extends EditCommand { final int nodeIndex; final DocumentNode newNode; + @override + String describe() => "Insert node at index $nodeIndex: $newNode"; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -613,13 +619,16 @@ class ReplaceNodeWithEmptyParagraphWithCaretRequest implements EditRequest { int get hashCode => nodeId.hashCode; } -class ReplaceNodeWithEmptyParagraphWithCaretCommand implements EditCommand { +class ReplaceNodeWithEmptyParagraphWithCaretCommand extends EditCommand { ReplaceNodeWithEmptyParagraphWithCaretCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -666,13 +675,19 @@ class DeleteContentRequest implements EditRequest { final DocumentRange documentRange; } -class DeleteContentCommand implements EditCommand { +class DeleteContentCommand extends EditCommand { DeleteContentCommand({ required this.documentRange, }); final DocumentRange documentRange; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => "Delete content within range: $documentRange"; + @override void execute(EditContext context, CommandExecutor executor) { _log.log('DeleteSelectionCommand', 'DocumentEditor: deleting selection: $documentRange'); @@ -1043,13 +1058,16 @@ class DeleteNodeRequest implements EditRequest { final String nodeId; } -class DeleteNodeCommand implements EditCommand { +class DeleteNodeCommand extends EditCommand { DeleteNodeCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { _log.log('DeleteNodeCommand', 'DocumentEditor: deleting node: $nodeId'); diff --git a/super_editor/lib/src/default_editor/paragraph.dart b/super_editor/lib/src/default_editor/paragraph.dart index 159dbf701..32753c8d3 100644 --- a/super_editor/lib/src/default_editor/paragraph.dart +++ b/super_editor/lib/src/default_editor/paragraph.dart @@ -50,6 +50,11 @@ class ParagraphNode extends TextNode { notifyListeners(); } + @override + ParagraphNode copy() { + return ParagraphNode(id: id, text: text.copyText(0), metadata: Map.from(metadata)); + } + @override bool operator ==(Object other) => identical(this, other) || @@ -321,7 +326,7 @@ class ChangeParagraphAlignmentRequest implements EditRequest { int get hashCode => nodeId.hashCode ^ alignment.hashCode; } -class ChangeParagraphAlignmentCommand implements EditCommand { +class ChangeParagraphAlignmentCommand extends EditCommand { const ChangeParagraphAlignmentCommand({ required this.nodeId, required this.alignment, @@ -330,6 +335,9 @@ class ChangeParagraphAlignmentCommand implements EditCommand { final String nodeId; final TextAlign alignment; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -384,7 +392,7 @@ class ChangeParagraphBlockTypeRequest implements EditRequest { int get hashCode => nodeId.hashCode ^ blockType.hashCode; } -class ChangeParagraphBlockTypeCommand implements EditCommand { +class ChangeParagraphBlockTypeCommand extends EditCommand { const ChangeParagraphBlockTypeCommand({ required this.nodeId, required this.blockType, @@ -393,6 +401,9 @@ class ChangeParagraphBlockTypeCommand implements EditCommand { final String nodeId; final Attribution? blockType; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -427,7 +438,7 @@ class CombineParagraphsRequest implements EditRequest { /// in reverse order, the command fizzles. /// /// If both nodes are not `ParagraphNode`s, the command fizzles. -class CombineParagraphsCommand implements EditCommand { +class CombineParagraphsCommand extends EditCommand { CombineParagraphsCommand({ required this.firstNodeId, required this.secondNodeId, @@ -436,6 +447,9 @@ class CombineParagraphsCommand implements EditCommand { final String firstNodeId; final String secondNodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing CombineParagraphsCommand'); @@ -530,7 +544,7 @@ final _defaultAttributionsToExtend = { /// given `splitPosition`, placing all text after `splitPosition` in a /// new `ParagraphNode` with the given `newNodeId`, inserted after the /// original node. -class SplitParagraphCommand implements EditCommand { +class SplitParagraphCommand extends EditCommand { SplitParagraphCommand({ required this.nodeId, required this.splitPosition, @@ -546,6 +560,9 @@ class SplitParagraphCommand implements EditCommand { // TODO: remove the attribution filter and move the decision to an EditReaction in #1296 final AttributionFilter attributionsToExtendToNewParagraph; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing SplitParagraphCommand'); @@ -659,11 +676,14 @@ class SplitParagraphCommand implements EditCommand { } } -class DeleteUpstreamAtBeginningOfParagraphCommand implements EditCommand { +class DeleteUpstreamAtBeginningOfParagraphCommand extends EditCommand { DeleteUpstreamAtBeginningOfParagraphCommand(this.node); final DocumentNode node; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { if (node is! ParagraphNode) { @@ -801,7 +821,7 @@ class DeleteUpstreamAtBeginningOfParagraphCommand implements EditCommand { } } -class Intention implements EditEvent { +class Intention extends EditEvent { Intention.start() : _isStart = true; Intention.end() : _isStart = false; @@ -865,13 +885,16 @@ ExecutionInstruction anyCharacterToInsertInParagraph({ return didInsertCharacter ? ExecutionInstruction.haltExecution : ExecutionInstruction.continueExecution; } -class DeleteParagraphCommand implements EditCommand { +class DeleteParagraphCommand extends EditCommand { DeleteParagraphCommand({ required this.nodeId, }); final String nodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing DeleteParagraphCommand'); @@ -1044,7 +1067,7 @@ class SetParagraphIndentRequest implements EditRequest { final int level; } -class SetParagraphIndentCommand implements EditCommand { +class SetParagraphIndentCommand extends EditCommand { const SetParagraphIndentCommand( this.nodeId, { required this.level, @@ -1081,7 +1104,7 @@ class IndentParagraphRequest implements EditRequest { final String nodeId; } -class IndentParagraphCommand implements EditCommand { +class IndentParagraphCommand extends EditCommand { const IndentParagraphCommand(this.nodeId); final String nodeId; @@ -1156,7 +1179,7 @@ class UnIndentParagraphRequest implements EditRequest { final String nodeId; } -class UnIndentParagraphCommand implements EditCommand { +class UnIndentParagraphCommand extends EditCommand { const UnIndentParagraphCommand(this.nodeId); final String nodeId; diff --git a/super_editor/lib/src/default_editor/super_editor.dart b/super_editor/lib/src/default_editor/super_editor.dart index c439419dc..0a7955aa6 100644 --- a/super_editor/lib/src/default_editor/super_editor.dart +++ b/super_editor/lib/src/default_editor/super_editor.dart @@ -32,6 +32,7 @@ import 'package:super_editor/src/infrastructure/platforms/mac/mac_ime.dart'; import 'package:super_editor/src/infrastructure/platforms/platform.dart'; import 'package:super_editor/src/infrastructure/signal_notifier.dart'; import 'package:super_editor/src/infrastructure/text_input.dart'; +import 'package:super_editor/src/undo_redo.dart'; import 'package:super_text_layout/super_text_layout.dart'; import '../infrastructure/document_gestures_interaction_overrides.dart'; @@ -1198,6 +1199,8 @@ final defaultKeyboardActions = [ pasteWhenCmdVIsPressed, copyWhenCmdCIsPressed, cutWhenCmdXIsPressed, + undoWhenCmdZOrCtrlZIsPressed, + redoWhenCmdShiftZOrCtrlShiftZIsPressed, collapseSelectionWhenEscIsPressed, selectAllWhenCmdAIsPressed, moveLeftAndRightWithArrowKeys, @@ -1246,6 +1249,8 @@ final defaultImeKeyboardActions = [ pasteWhenCmdVIsPressed, copyWhenCmdCIsPressed, cutWhenCmdXIsPressed, + undoWhenCmdZOrCtrlZIsPressed, + redoWhenCmdShiftZOrCtrlShiftZIsPressed, selectAllWhenCmdAIsPressed, cmdBToToggleBold, cmdIToToggleItalics, diff --git a/super_editor/lib/src/default_editor/tasks.dart b/super_editor/lib/src/default_editor/tasks.dart index 08a8333a7..e1744f80a 100644 --- a/super_editor/lib/src/default_editor/tasks.dart +++ b/super_editor/lib/src/default_editor/tasks.dart @@ -1,7 +1,24 @@ +import 'package:attributed_text/attributed_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/document.dart'; +import 'package:super_editor/src/core/document_composer.dart'; +import 'package:super_editor/src/core/document_layout.dart'; +import 'package:super_editor/src/core/document_selection.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/core/editor.dart'; +import 'package:super_editor/src/core/styles.dart'; import 'package:super_editor/src/default_editor/blocks/indentation.dart'; -import 'package:super_editor/super_editor.dart'; +import 'package:super_editor/src/default_editor/multi_node_editing.dart'; +import 'package:super_editor/src/default_editor/paragraph.dart'; +import 'package:super_editor/src/default_editor/text.dart'; +import 'package:super_editor/src/infrastructure/_logging.dart'; +import 'package:super_editor/src/infrastructure/attributed_text_styles.dart'; +import 'package:super_editor/src/infrastructure/composable_text.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +import 'attributions.dart'; +import 'layout_single_column/layout_single_column.dart'; /// This file includes everything needed to add the concept of a task /// to Super Editor. This includes: @@ -63,6 +80,16 @@ class TaskNode extends TextNode { return other is TaskNode && isComplete == other.isComplete && text == other.text; } + @override + TaskNode copy() { + return TaskNode( + id: id, + text: text.copyText(0), + metadata: Map.from(metadata), + isComplete: isComplete, + ); + } + @override bool operator ==(Object other) => identical(this, other) || @@ -570,12 +597,15 @@ class ChangeTaskCompletionRequest implements EditRequest { int get hashCode => nodeId.hashCode ^ isComplete.hashCode; } -class ChangeTaskCompletionCommand implements EditCommand { +class ChangeTaskCompletionCommand extends EditCommand { ChangeTaskCompletionCommand({required this.nodeId, required this.isComplete}); final String nodeId; final bool isComplete; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final taskNode = context.find(Editor.documentKey).getNodeById(nodeId); @@ -614,7 +644,7 @@ class ConvertParagraphToTaskRequest implements EditRequest { int get hashCode => nodeId.hashCode ^ isComplete.hashCode; } -class ConvertParagraphToTaskCommand implements EditCommand { +class ConvertParagraphToTaskCommand extends EditCommand { const ConvertParagraphToTaskCommand({ required this.nodeId, this.isComplete = false, @@ -623,6 +653,9 @@ class ConvertParagraphToTaskCommand implements EditCommand { final String nodeId; final bool isComplete; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -666,7 +699,7 @@ class ConvertTaskToParagraphRequest implements EditRequest { int get hashCode => nodeId.hashCode ^ paragraphMetadata.hashCode; } -class ConvertTaskToParagraphCommand implements EditCommand { +class ConvertTaskToParagraphCommand extends EditCommand { const ConvertTaskToParagraphCommand({ required this.nodeId, this.paragraphMetadata, @@ -675,6 +708,9 @@ class ConvertTaskToParagraphCommand implements EditCommand { final String nodeId; final Map? paragraphMetadata; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -710,7 +746,7 @@ class SplitExistingTaskRequest implements EditRequest { final String? newNodeId; } -class SplitExistingTaskCommand implements EditCommand { +class SplitExistingTaskCommand extends EditCommand { const SplitExistingTaskCommand({ required this.nodeId, required this.splitOffset, @@ -721,6 +757,9 @@ class SplitExistingTaskCommand implements EditCommand { final int splitOffset; final String? newNodeId; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext editContext, CommandExecutor executor) { final document = editContext.find(Editor.documentKey); @@ -803,7 +842,7 @@ class IndentTaskRequest implements EditRequest { final String nodeId; } -class IndentTaskCommand implements EditCommand { +class IndentTaskCommand extends EditCommand { const IndentTaskCommand(this.nodeId); final String nodeId; @@ -852,7 +891,7 @@ class UnIndentTaskRequest implements EditRequest { final String nodeId; } -class UnIndentTaskCommand implements EditCommand { +class UnIndentTaskCommand extends EditCommand { const UnIndentTaskCommand(this.nodeId); final String nodeId; @@ -924,7 +963,7 @@ class SetTaskIndentRequest implements EditRequest { final int indent; } -class SetTaskIndentCommand implements EditCommand { +class SetTaskIndentCommand extends EditCommand { const SetTaskIndentCommand(this.nodeId, this.indent); final String nodeId; @@ -949,9 +988,9 @@ class SetTaskIndentCommand implements EditCommand { } } -class UpdateSubTaskIndentAfterTaskDeletionReaction implements EditReaction { +class UpdateSubTaskIndentAfterTaskDeletionReaction extends EditReaction { @override - void react(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { + void modifyContent(EditContext editorContext, RequestDispatcher requestDispatcher, List changeList) { final didDeleteTask = changeList .whereType() .where((edit) => edit.change is NodeRemovedEvent && (edit.change as NodeRemovedEvent).removedNode is TaskNode) diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart index bce79c8aa..cf91f79b1 100644 --- a/super_editor/lib/src/default_editor/text.dart +++ b/super_editor/lib/src/default_editor/text.dart @@ -165,6 +165,11 @@ class TextNode extends DocumentNode with ChangeNotifier { return other is TextNode && text == other.text && super.hasEquivalentContent(other); } + @override + TextNode copy() { + return TextNode(id: id, text: text.copyText(0), metadata: Map.from(metadata)); + } + @override String toString() => '[TextNode] - text: $text, metadata: ${copyMetadata()}'; @@ -1187,7 +1192,7 @@ class AddTextAttributionsRequest implements EditRequest { // TODO: the add/remove/toggle commands are almost identical except for what they // do to ranges of text. Pull out the common range calculation behavior. /// Applies the given `attributions` to the given `documentSelection`. -class AddTextAttributionsCommand implements EditCommand { +class AddTextAttributionsCommand extends EditCommand { AddTextAttributionsCommand({ required this.documentRange, required this.attributions, @@ -1198,6 +1203,9 @@ class AddTextAttributionsCommand implements EditCommand { final Set attributions; final bool autoMerge; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing AddTextAttributionsCommand'); @@ -1306,7 +1314,7 @@ class RemoveTextAttributionsRequest implements EditRequest { } /// Removes the given `attributions` from the given `documentSelection`. -class RemoveTextAttributionsCommand implements EditCommand { +class RemoveTextAttributionsCommand extends EditCommand { RemoveTextAttributionsCommand({ required this.documentRange, required this.attributions, @@ -1315,6 +1323,9 @@ class RemoveTextAttributionsCommand implements EditCommand { final DocumentRange documentRange; final Set attributions; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { editorDocLog.info('Executing RemoveTextAttributionsCommand'); @@ -1425,7 +1436,7 @@ class ToggleTextAttributionsRequest implements EditRequest { /// if none of the content in the selection contains any of the /// given `attributions`. Otherwise, all the given `attributions` /// are removed from the content within the `documentSelection`. -class ToggleTextAttributionsCommand implements EditCommand { +class ToggleTextAttributionsCommand extends EditCommand { ToggleTextAttributionsCommand({ required this.documentRange, required this.attributions, @@ -1434,6 +1445,9 @@ class ToggleTextAttributionsCommand implements EditCommand { final DocumentRange documentRange; final Set attributions; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + // TODO: The structure of this command looks nearly identical to the two other attribution // commands above. We collect nodes and then we loop through them to apply an operation. // Try to de-dup this code. Maybe use a private base class called ChangeTextAttributionsCommand @@ -1593,6 +1607,10 @@ class AttributionChangeEvent extends NodeChangeEvent { final SpanRange range; final Set attributions; + @override + String describe() => + "${change == AttributionChange.added ? "Added" : "Removed"} attributions ($nodeId) - ${range.start} -> ${range.end}: $attributions"; + @override String toString() => "AttributionChangeEvent ('$nodeId' - ${range.start} -> ${range.end} ($change): '$attributions')"; @@ -1626,7 +1644,7 @@ class ChangeSingleColumnLayoutComponentStylesRequest implements EditRequest { final SingleColumnLayoutComponentStyles styles; } -class ChangeSingleColumnLayoutComponentStylesCommand implements EditCommand { +class ChangeSingleColumnLayoutComponentStylesCommand extends EditCommand { ChangeSingleColumnLayoutComponentStylesCommand({ required this.nodeId, required this.styles, @@ -1635,6 +1653,9 @@ class ChangeSingleColumnLayoutComponentStylesCommand implements EditCommand { final String nodeId; final SingleColumnLayoutComponentStyles styles; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -1662,7 +1683,7 @@ class InsertTextRequest implements EditRequest { final Set attributions; } -class InsertTextCommand implements EditCommand { +class InsertTextCommand extends EditCommand { InsertTextCommand({ required this.documentPosition, required this.textToInsert, @@ -1673,6 +1694,13 @@ class InsertTextCommand implements EditCommand { final String textToInsert; final Set attributions; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + + @override + String describe() => + "Insert text - ${documentPosition.nodeId} @ ${(documentPosition.nodePosition as TextNodePosition).offset} - '$textToInsert'"; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -1730,6 +1758,9 @@ class TextInsertionEvent extends NodeChangeEvent { final int offset; final AttributedText text; + @override + String describe() => "Inserted text ($nodeId) @ $offset: '${text.text}'"; + @override String toString() => "TextInsertionEvent ('$nodeId' - $offset -> '${text.text}')"; @@ -1756,6 +1787,9 @@ class TextDeletedEvent extends NodeChangeEvent { final int offset; final AttributedText deletedText; + @override + String describe() => "Deleted text ($nodeId) @ $offset: ${deletedText.text}"; + @override String toString() => "TextDeletedEvent ('$nodeId' - $offset -> '${deletedText.text}')"; @@ -1823,7 +1857,7 @@ class InsertAttributedTextRequest implements EditRequest { final AttributedText textToInsert; } -class InsertAttributedTextCommand implements EditCommand { +class InsertAttributedTextCommand extends EditCommand { InsertAttributedTextCommand({ required this.documentPosition, required this.textToInsert, @@ -1832,6 +1866,9 @@ class InsertAttributedTextCommand implements EditCommand { final DocumentPosition documentPosition; final AttributedText textToInsert; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); diff --git a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart index 9fd89db91..099a1183c 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/action_tags.dart @@ -112,7 +112,10 @@ class SubmitComposingActionTagRequest implements EditRequest { const SubmitComposingActionTagRequest(); } -class SubmitComposingActionTagCommand implements EditCommand { +class SubmitComposingActionTagCommand extends EditCommand { + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -183,11 +186,14 @@ class CancelComposingActionTagRequest implements EditRequest { int get hashCode => tagRule.hashCode; } -class CancelComposingActionTagCommand implements EditCommand { +class CancelComposingActionTagCommand extends EditCommand { const CancelComposingActionTagCommand(this._tagRule); final TagRule _tagRule; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -258,7 +264,7 @@ class CancelComposingActionTagCommand implements EditCommand { } } -class ActionTagComposingReaction implements EditReaction { +class ActionTagComposingReaction extends EditReaction { ActionTagComposingReaction({ required TagRule tagRule, required OnUpdateComposingActionTag onUpdateComposingActionTag, diff --git a/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart index e5bea1a5d..17db46005 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/pattern_tags.dart @@ -173,6 +173,11 @@ class PatternTagIndex with ChangeNotifier implements Editable { notifyListeners(); } } + + @override + void reset() { + _tags.clear(); + } } /// An [EditReaction] that creates, updates, and removes pattern tags. @@ -194,7 +199,7 @@ class PatternTagIndex with ChangeNotifier implements Editable { /// #. /// ## /// -class PatternTagReaction implements EditReaction { +class PatternTagReaction extends EditReaction { PatternTagReaction({ TagRule tagRule = hashTagRule, }) : _tagRule = tagRule; @@ -465,6 +470,11 @@ class PatternTagReaction implements EditReaction { } } + if (spanRemovals.isEmpty) { + // We didn't find any tags to break up. No need to submit change requests. + return; + } + // Execute the attribution removals and additions. requestDispatcher.execute([ // Remove the original multi-tag attribution spans. diff --git a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart index e820bcaf2..ae3cd7b33 100644 --- a/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart +++ b/super_editor/lib/src/default_editor/text_tokenizing/stable_tags.dart @@ -162,7 +162,7 @@ class FillInComposingStableTagRequest implements EditRequest { int get hashCode => tag.hashCode ^ tagRule.hashCode; } -class FillInComposingUserTagCommand implements EditCommand { +class FillInComposingUserTagCommand extends EditCommand { const FillInComposingUserTagCommand( this._tag, this._tagRule, @@ -171,6 +171,9 @@ class FillInComposingUserTagCommand implements EditCommand { final String _tag; final TagRule _tagRule; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -278,11 +281,14 @@ class CancelComposingStableTagRequest implements EditRequest { int get hashCode => tagRule.hashCode; } -class CancelComposingStableTagCommand implements EditCommand { +class CancelComposingStableTagCommand extends EditCommand { const CancelComposingStableTagCommand(this._tagRule); final TagRule _tagRule; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); @@ -363,7 +369,7 @@ extension StableTagIndexEditable on EditContext { /// An [EditReaction] that creates, updates, and removes composing stable tags, and commits those /// composing tags, causing them to become uneditable. -class TagUserReaction implements EditReaction { +class TagUserReaction extends EditReaction { const TagUserReaction({ required TagRule tagRule, this.onUpdateComposingStableTag, @@ -1076,6 +1082,13 @@ class StableTagIndex with ChangeNotifier implements Editable { notifyListeners(); } } + + @override + void reset() { + _composingStableTag.value = null; + _committedTags.clear(); + _cancelledTags.clear(); + } } class ComposingStableTag { @@ -1100,7 +1113,7 @@ class ComposingStableTag { } /// An [EditReaction] that prevents partial selection of a stable user tag. -class AdjustSelectionAroundTagReaction implements EditReaction { +class AdjustSelectionAroundTagReaction extends EditReaction { const AdjustSelectionAroundTagReaction(this._tagRule); final TagRule _tagRule; diff --git a/super_editor/lib/src/infrastructure/_logging.dart b/super_editor/lib/src/infrastructure/_logging.dart index 31a82776c..bc1092222 100644 --- a/super_editor/lib/src/infrastructure/_logging.dart +++ b/super_editor/lib/src/infrastructure/_logging.dart @@ -4,6 +4,7 @@ import 'package:logging/logging.dart' as logging; class LogNames { static const editor = 'editor'; + static const editorEdits = 'editor.edits'; static const editorPolicies = 'editor.policies'; static const editorScrolling = 'editor.scrolling'; static const editorGestures = 'editor.gestures'; @@ -48,6 +49,7 @@ class LogNames { } final editorLog = logging.Logger(LogNames.editor); +final editorEditsLog = logging.Logger(LogNames.editorEdits); final editorPoliciesLog = logging.Logger(LogNames.editorPolicies); final editorScrollingLog = logging.Logger(LogNames.editorScrolling); final editorGesturesLog = logging.Logger(LogNames.editorGestures); diff --git a/super_editor/lib/src/undo_redo.dart b/super_editor/lib/src/undo_redo.dart new file mode 100644 index 000000000..de6002c52 --- /dev/null +++ b/super_editor/lib/src/undo_redo.dart @@ -0,0 +1,43 @@ +import 'package:flutter/services.dart'; +import 'package:super_editor/src/core/edit_context.dart'; +import 'package:super_editor/src/infrastructure/keyboard.dart'; + +/// Undoes the most recent change within the [Editor]. +ExecutionInstruction undoWhenCmdZOrCtrlZIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyZ || + !keyEvent.isPrimaryShortcutKeyPressed || + HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.undo(); + + return ExecutionInstruction.haltExecution; +} + +/// Re-runs the most recently undone change within the [Editor]. +ExecutionInstruction redoWhenCmdShiftZOrCtrlShiftZIsPressed({ + required SuperEditorContext editContext, + required KeyEvent keyEvent, +}) { + if (keyEvent is! KeyDownEvent && keyEvent is! KeyRepeatEvent) { + return ExecutionInstruction.continueExecution; + } + + if (keyEvent.logicalKey != LogicalKeyboardKey.keyZ || + !keyEvent.isPrimaryShortcutKeyPressed || + !HardwareKeyboard.instance.isShiftPressed) { + return ExecutionInstruction.continueExecution; + } + + editContext.editor.redo(); + + return ExecutionInstruction.haltExecution; +} diff --git a/super_editor/pubspec.yaml b/super_editor/pubspec.yaml index 14f250b96..b4e08fef9 100644 --- a/super_editor/pubspec.yaml +++ b/super_editor/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: flutter_test: sdk: flutter flutter_test_robots: ^0.0.24 + clock: ^1.1.1 dependency_overrides: # Override to local mono-repo path so devs can test this repo diff --git a/super_editor/test/super_editor/infrastructure/editor_test.dart b/super_editor/test/super_editor/infrastructure/editor_test.dart index 189b743b1..f7467a6a4 100644 --- a/super_editor/test/super_editor/infrastructure/editor_test.dart +++ b/super_editor/test/super_editor/infrastructure/editor_test.dart @@ -161,9 +161,11 @@ void main() { }, requestHandlers: List.from(defaultRequestHandlers), reactionPipeline: [ - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { - reactionCount += 1; - }), + FunctionalEditReaction( + react: (editorContext, requestDispatcher, changeList) { + reactionCount += 1; + }, + ), ], ); @@ -201,7 +203,7 @@ void main() { }, requestHandlers: List.from(defaultRequestHandlers), reactionPipeline: [ - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { TextInsertionEvent? insertEEvent; for (final edit in changeList) { if (edit is! DocumentEdit) { @@ -290,7 +292,7 @@ void main() { requestHandlers: List.from(defaultRequestHandlers), reactionPipeline: [ // Reaction 1 causes a change - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { TextInsertionEvent? insertHEvent; for (final edit in changeList) { if (edit is! DocumentEdit) { @@ -321,7 +323,7 @@ void main() { ]); }), // Reaction 2 verifies that it sees the change event from reaction 1. - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { TextInsertionEvent? insertEEvent; for (final edit in changeList) { if (edit is! DocumentEdit) { @@ -375,7 +377,7 @@ void main() { }, requestHandlers: List.from(defaultRequestHandlers), reactionPipeline: [ - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { reactionRunCount += 1; // We expect this reaction to run after we execute a command, but we don't @@ -607,7 +609,7 @@ void main() { final editorPieces = _createStandardEditor( initialDocument: longTextDoc(), additionalReactions: [ - FunctionalEditReaction((editorContext, requestDispatcher, changeList) { + FunctionalEditReaction(react: (editorContext, requestDispatcher, changeList) { expect(changeList.length, 1); final event = changeList.first as DocumentEdit; @@ -690,11 +692,14 @@ class _ExpandingCommandRequest implements EditRequest { final int levelsOfGeneration; } -class _ExpandingCommand implements EditCommand { +class _ExpandingCommand extends EditCommand { const _ExpandingCommand(this.request); final _ExpandingCommandRequest request; + @override + HistoryBehavior get historyBehavior => HistoryBehavior.undoable; + @override void execute(EditContext context, CommandExecutor executor) { final document = context.find(Editor.documentKey); diff --git a/super_editor/test/super_editor/super_editor_undo_redo_test.dart b/super_editor/test/super_editor/super_editor_undo_redo_test.dart new file mode 100644 index 000000000..e7b55e9da --- /dev/null +++ b/super_editor/test/super_editor/super_editor_undo_redo_test.dart @@ -0,0 +1,445 @@ +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_robots/flutter_test_robots.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 'package:super_editor_markdown/super_editor_markdown.dart'; + +import 'supereditor_test_tools.dart'; + +void main() { + group("Super Editor > undo redo >", () { + group("text insertion >", () { + testWidgets("insert a word", (tester) async { + final document = deserializeMarkdownToDocument("Hello world"); + final composer = MutableDocumentComposer(); + final editor = createDefaultDocumentEditor(document: document, composer: composer); + final paragraphId = document.nodes.first.id; + + editor.execute([ + ChangeSelectionRequest( + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + ), + SelectionChangeType.placeCaret, + SelectionReason.userInteraction, + ) + ]); + + editor.execute([ + InsertTextRequest( + documentPosition: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + textToInsert: "another", + attributions: {}, + ), + ]); + + expect(serializeDocumentToMarkdown(document), "Hello another world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + + // Undo the event. + editor.undo(); + + expect(serializeDocumentToMarkdown(document), "Hello world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 6), + ), + ), + ); + + // Redo the event. + editor.redo(); + + expect(serializeDocumentToMarkdown(document), "Hello another world"); + expect( + composer.selection, + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: paragraphId, + nodePosition: const TextNodePosition(offset: 13), + ), + ), + ); + }); + + testWidgetsOnMac("type by character", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type characters. + await tester.typeImeText("Hello"); + + expect(SuperEditorInspector.findTextInComponent("1").text, "Hello"); + expect( + SuperEditorInspector.findDocumentSelection(), + const DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: "1", + nodePosition: TextNodePosition(offset: 5), + ), + ), + ); + + // --- Undo character insertions --- + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("Hell", "1", 4); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("Hel", "1", 3); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("He", "1", 2); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("H", "1", 1); + + await tester.pressCmdZ(tester); + _expectDocumentWithCaret("", "1", 0); + + //----- Redo Changes ---- + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("H", "1", 1); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("He", "1", 2); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hel", "1", 3); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hell", "1", 4); + + await tester.pressCmdShiftZ(tester); + _expectDocumentWithCaret("Hello", "1", 5); + }); + }); + + group("content conversions >", () { + testWidgetsOnMac("paragraph to header", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a header node. + await tester.typeImeText("# "); + + // Ensure that the paragraph is now a header. + final document = editContext.document; + var paragraph = document.nodes.first as ParagraphNode; + expect(paragraph.metadata['blockType'], header1Attribution); + expect(SuperEditorInspector.findTextInComponent(document.nodes.first.id).text, ""); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the header attribution is gone. + paragraph = document.nodes.first as ParagraphNode; + expect(paragraph.metadata['blockType'], paragraphAttribution); + expect(SuperEditorInspector.findTextInComponent(document.nodes.first.id).text, "# "); + }); + + testWidgetsOnMac("dashes to em dash", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to an "em" dash. + await tester.typeImeText("--"); + + // Ensure that the double dashes are now an "em" dash. + expect(SuperEditorInspector.findTextInComponent("1").text, "—"); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the em dash was reverted to the regular dashes. + expect(SuperEditorInspector.findTextInComponent("1").text, "--"); + + // Continue typing. + await tester.typeImeText(" "); + + // Ensure that the dashes weren't reconverted into an em dash. + expect(SuperEditorInspector.findTextInComponent("1").text, "-- "); + }); + + testWidgetsOnMac("paragraph to list item", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a list item node. + await tester.typeImeText("1. "); + + // Ensure that the paragraph is now a list item. + final document = editContext.document; + var node = document.nodes.first as TextNode; + expect(node, isA()); + expect(SuperEditorInspector.findTextInComponent(document.nodes.first.id).text, ""); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the node is back to a paragraph. + node = document.nodes.first as TextNode; + expect(node, isA()); + expect(SuperEditorInspector.findTextInComponent(document.nodes.first.id).text, "1. "); + }); + + testWidgetsOnMac("url to a link", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type text that causes a conversion to a link. + await tester.typeImeText("google.com "); + + // Ensure that the URL is now linkified. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansByFilter((a) => a is LinkAttribution), + { + const AttributionSpan( + attribution: LinkAttribution("https://google.com"), + start: 0, + end: 9, + ), + }, + ); + + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the URL is no longer linkified. + expect( + SuperEditorInspector.findTextInComponent("1").getAttributionSpansByFilter((a) => a is LinkAttribution), + const {}, + ); + }); + + testWidgetsOnMac("paragraph to horizontal rule", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await tester.typeImeText("--- "); + expect(editContext.document.nodes.first, isA()); + + await tester.pressCmdZ(tester); + await tester.pump(); + + expect(editContext.document.nodes.first, isA()); + expect(SuperEditorInspector.findTextInComponent(editContext.document.nodes.first.id).text, "—- "); + }); + }); + + testWidgetsOnMac("pasted content", (tester) async { + final editContext = await tester // + .createDocument() + .withSingleEmptyParagraph() + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Paste multiple nodes of content. + tester.simulateClipboard(); + await tester.setSimulatedClipboardContent(''' +This is paragraph 1 +This is paragraph 2 +This is paragraph 3'''); + await tester.pressCmdV(); + + // Ensure the pasted content was applied as expected. + final document = editContext.document; + expect(document.nodes.length, 3); + expect(SuperEditorInspector.findTextInComponent(document.nodes[0].id).text, "This is paragraph 1"); + expect(SuperEditorInspector.findTextInComponent(document.nodes[1].id).text, "This is paragraph 2"); + expect(SuperEditorInspector.findTextInComponent(document.nodes[2].id).text, "This is paragraph 3"); + + // Undo the paste. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure we're back to a single empty paragraph. + expect(document.nodes.length, 1); + expect(SuperEditorInspector.findTextInComponent(document.nodes[0].id).text, ""); + + // Redo the paste + // TODO: remove WidgetTester as required argument to this robot method + await tester.pressCmdShiftZ(tester); + await tester.pump(); + + // Ensure the pasted content was applied as expected. + expect(document.nodes.length, 3); + expect(SuperEditorInspector.findTextInComponent(document.nodes[0].id).text, "This is paragraph 1"); + expect(SuperEditorInspector.findTextInComponent(document.nodes[1].id).text, "This is paragraph 2"); + expect(SuperEditorInspector.findTextInComponent(document.nodes[2].id).text, "This is paragraph 3"); + }); + + group("transaction grouping >", () { + group("text merging >", () { + testWidgetsOnMac("merges rapidly inserted text", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withHistoryGroupingPolicy(const MergeRapidTextInputPolicy()) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Type characters quickly. + await tester.typeImeText("Hello"); + + // Ensure our typed text exists. + expect(SuperEditorInspector.findTextInComponent("1").text, "Hello"); + + // Undo the typing. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the whole word was undone. + expect(SuperEditorInspector.findTextInComponent("1").text, ""); + }); + + testWidgetsOnMac("separates text typed later", (tester) async { + await tester // + .createDocument() + .withSingleEmptyParagraph() + .withHistoryGroupingPolicy(const MergeRapidTextInputPolicy()) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 0, 0)), () async { + // Type characters quickly. + await tester.typeImeText("Hel"); + }); + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 0, 150)), () async { + // Type characters quickly. + await tester.typeImeText("lo "); + }); + + // Wait a bit. + await tester.pump(const Duration(seconds: 3)); + + await withClock(Clock(() => DateTime(2024, 05, 26, 12, 0, 3, 0)), () async { + // Type characters quickly. + await tester.typeImeText("World!"); + }); + + // Ensure our typed text exists. + expect(SuperEditorInspector.findTextInComponent("1").text, "Hello World!"); + + // Undo the typing. + await tester.pressCmdZ(tester); + await tester.pump(); + + // Ensure that the text typed later was removed, but the text typed earlier + // remains. + expect(SuperEditorInspector.findTextInComponent("1").text, "Hello "); + }); + }); + + group("selection and composing >", () { + testWidgetsOnMac("merges transactions with only selection and composing changes", (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .withHistoryGroupingPolicy(defaultMergePolicy) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Ensure we start with one history transaction for placing the caret. + final editor = testContext.editor; + expect(editor.history.length, 1); + + // Move the selection around a few times. + await tester.placeCaretInParagraph("2", 5); + + await tester.placeCaretInParagraph("3", 3); + + await tester.placeCaretInParagraph("4", 0); + + // Ensure that all selection changes were merged into the initial transaction. + expect(editor.history.length, 1); + }); + + testWidgetsOnMac("does not merge transactions when non-selection changes are present", (tester) async { + final testContext = await tester // + .createDocument() + .withLongDoc() + .withHistoryGroupingPolicy(defaultMergePolicy) + .pump(); + + await tester.placeCaretInParagraph("1", 0); + + // Ensure we start with one history transaction for placing the caret. + final editor = testContext.editor; + expect(editor.history.length, 1); + + // Type a few characters. + await tester.typeImeText("Hello "); + + // Move caret to start of paragraph. + await tester.placeCaretInParagraph("1", 0); + + // Type a few more characters. + await tester.typeImeText("World "); + + // Ensure we have 4 transactions: selection, typing+selection, typing. + expect(editor.history.length, 3); + }); + }); + }); + }); +} + +void _expectDocumentWithCaret(String documentContent, String caretNodeId, int caretOffset) { + expect(serializeDocumentToMarkdown(SuperEditorInspector.findDocument()!), documentContent); + expect( + SuperEditorInspector.findDocumentSelection(), + DocumentSelection.collapsed( + position: DocumentPosition( + nodeId: caretNodeId, + nodePosition: TextNodePosition(offset: caretOffset), + ), + ), + ); +} diff --git a/super_editor/test/super_editor/supereditor_component_selection_test.dart b/super_editor/test/super_editor/supereditor_component_selection_test.dart index eb82e48de..2657522ad 100644 --- a/super_editor/test/super_editor/supereditor_component_selection_test.dart +++ b/super_editor/test/super_editor/supereditor_component_selection_test.dart @@ -651,6 +651,11 @@ class _ButtonNode extends BlockNode with ChangeNotifier { @override String? copyContent(dynamic selection) => ''; + @override + DocumentNode copy() { + return _ButtonNode(id: id); + } + @override bool operator ==(Object other) => identical(this, other) || diff --git a/super_editor/test/super_editor/supereditor_components_test.dart b/super_editor/test/super_editor/supereditor_components_test.dart index bcab35480..e02c13c49 100644 --- a/super_editor/test/super_editor/supereditor_components_test.dart +++ b/super_editor/test/super_editor/supereditor_components_test.dart @@ -208,9 +208,14 @@ class _FakeImageComponentBuilder implements ComponentBuilder { class _UnkownNode extends BlockNode with ChangeNotifier { _UnkownNode({required this.id}); + @override + final String id; + @override String? copyContent(NodeSelection selection) => ''; @override - final String id; + _UnkownNode copy() { + return _UnkownNode(id: id); + } } diff --git a/super_editor/test/super_editor/supereditor_scrolling_test.dart b/super_editor/test/super_editor/supereditor_scrolling_test.dart index 97ed8576b..ab465457d 100644 --- a/super_editor/test/super_editor/supereditor_scrolling_test.dart +++ b/super_editor/test/super_editor/supereditor_scrolling_test.dart @@ -1451,8 +1451,8 @@ void main() { .withScrollController(scrollController) .pump(); await tester.tapInParagraph('1', 0); - final gesture = await tester.startGesture(Offset(100, 100), kind: PointerDeviceKind.touch); - await gesture.moveBy(Offset(0, -100)); + final gesture = await tester.startGesture(const Offset(100, 100), kind: PointerDeviceKind.touch); + await gesture.moveBy(const Offset(0, -100)); await tester.pumpAndSettle(); final pixels = scrollController.position.pixels; // This should not change scroll position. diff --git a/super_editor/test/super_editor/supereditor_test_tools.dart b/super_editor/test/super_editor/supereditor_test_tools.dart index 8f6a18727..50606cf59 100644 --- a/super_editor/test/super_editor/supereditor_test_tools.dart +++ b/super_editor/test/super_editor/supereditor_test_tools.dart @@ -249,6 +249,11 @@ class TestSuperEditorConfigurator { return this; } + TestSuperEditorConfigurator withHistoryGroupingPolicy(HistoryGroupingPolicy policy) { + _config.historyGroupPolicy = policy; + return this; + } + /// Configures the [SuperEditor] to constrain its maxHeight and maxWidth using the given [size]. TestSuperEditorConfigurator withEditorSize(ui.Size? size) { _config.editorSize = size; @@ -422,7 +427,11 @@ class TestSuperEditorConfigurator { final layoutKey = _config.layoutKey!; final focusNode = _config.focusNode ?? FocusNode(); final composer = MutableDocumentComposer(initialSelection: _config.selection); - final editor = createDefaultDocumentEditor(document: _config.document, composer: composer) + final editor = createDefaultDocumentEditor( + document: _config.document, + composer: composer, + historyGroupingPolicy: _config.historyGroupPolicy ?? neverMergePolicy, + ) ..requestHandlers.insertAll(0, _config.addedRequestHandlers) ..reactionPipeline.insertAll(0, _config.addedReactions); @@ -671,6 +680,7 @@ class SuperEditorTestConfiguration { ScrollController? scrollController; bool insideCustomScrollView = false; DocumentGestureMode? gestureMode; + HistoryGroupingPolicy? historyGroupPolicy; TextInputSource? inputSource; SuperEditorSelectionPolicies? selectionPolicies; SelectionStyles? selectionStyles; diff --git a/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart b/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart index 89359c98f..f9b220988 100644 --- a/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart +++ b/super_editor/test/super_textfield/attributed_text_editing_controller_test.dart @@ -808,8 +808,8 @@ void main() { int listenerNotifyCount = 0; final controller = AttributedTextEditingController( text: AttributedText('my text'), - selection: TextSelection.collapsed(offset: 7), - composingRegion: TextRange(start: 3, end: 7), + selection: const TextSelection.collapsed(offset: 7), + composingRegion: const TextRange(start: 3, end: 7), ) ..composingAttributions = { boldAttribution, @@ -837,8 +837,8 @@ void main() { // throw a compile error, at which time it will be safe to remove it. controller ..text = AttributedText('my text') - ..selection = TextSelection.collapsed(offset: 7) - ..composingRegion = TextRange(start: 3, end: 7) + ..selection = const TextSelection.collapsed(offset: 7) + ..composingRegion = const TextRange(start: 3, end: 7) ..composingAttributions = {boldAttribution}; listenerNotifyCount = 0; @@ -859,8 +859,8 @@ void main() { int listenerNotifyCount = 0; final controller = AttributedTextEditingController( text: AttributedText('my text'), - selection: TextSelection.collapsed(offset: 7), - composingRegion: TextRange(start: 3, end: 7), + selection: const TextSelection.collapsed(offset: 7), + composingRegion: const TextRange(start: 3, end: 7), ) ..composingAttributions = { boldAttribution, diff --git a/super_editor_markdown/lib/src/image_syntax.dart b/super_editor_markdown/lib/src/image_syntax.dart index acf9b6a24..3e4f4e1d6 100644 --- a/super_editor_markdown/lib/src/image_syntax.dart +++ b/super_editor_markdown/lib/src/image_syntax.dart @@ -31,11 +31,11 @@ class SuperEditorImageSyntax extends md.LinkSyntax { String? tag, required List Function() getChildren, }) { - var text = parser.source!.substring(opener.endPos, parser.pos); + var text = parser.source.substring(opener.endPos, parser.pos); // The current character is the `]` that closed the link text. Examine the // next character, to determine what type of link we might have (a '(' // means a possible inline link; otherwise a possible reference link). - if (parser.pos + 1 >= parser.source!.length) { + if (parser.pos + 1 >= parser.source.length) { // The `]` is at the end of the document, but this may still be a valid // shortcut reference link. return _tryCreateReferenceLink(parser, text, getChildren: getChildren); @@ -67,7 +67,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { parser.advanceBy(1); // At this point, we've matched `[...][`. Maybe a *full* reference link, // like `[foo][bar]` or a *collapsed* reference link, like `[foo][]`. - if (parser.pos + 1 < parser.source!.length && parser.charAt(parser.pos + 1) == AsciiTable.rightBracket) { + if (parser.pos + 1 < parser.source.length && parser.charAt(parser.pos + 1) == AsciiTable.rightBracket) { // That opening `[` is not actually part of the link. Maybe a // *shortcut* reference link (followed by a `[`). parser.advanceBy(1); @@ -100,7 +100,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { // Parse an optional width. final width = _tryParseNumber(parser); - final downstreamCharacter = parser.source!.substring(parser.pos, parser.pos + 1); + final downstreamCharacter = parser.source.substring(parser.pos, parser.pos + 1); if (downstreamCharacter.toLowerCase() != 'x') { // The image size must have a "x" between the width and height, but the input doesn't. Fizzle. return null; diff --git a/super_editor_markdown/lib/src/markdown_inline_upstream_plugin.dart b/super_editor_markdown/lib/src/markdown_inline_upstream_plugin.dart index ad3d055d5..bf95fb9df 100644 --- a/super_editor_markdown/lib/src/markdown_inline_upstream_plugin.dart +++ b/super_editor_markdown/lib/src/markdown_inline_upstream_plugin.dart @@ -66,7 +66,7 @@ const defaultUpstreamInlineMarkdownParsers = [ /// Parsing of links is handled differently than all other upstream syntax. Links use a fairly /// complicated syntax, so they're identified with a regular expression. All other upstream /// inline syntaxes are parsed character by character, moving upstream from the caret position. -class MarkdownInlineUpstreamSyntaxReaction implements EditReaction { +class MarkdownInlineUpstreamSyntaxReaction extends EditReaction { const MarkdownInlineUpstreamSyntaxReaction(this._parsers); final List _parsers; @@ -153,6 +153,10 @@ class MarkdownInlineUpstreamSyntaxReaction implements EditReaction { return const []; } + final newCaretPosition = DocumentPosition( + nodeId: editedNode.id, + nodePosition: TextNodePosition(offset: markdownRun.start + markdownRun.replacementText.length), + ); return [ // Delete the whole run of Markdown text, e.g., "**my bold**". DeleteContentRequest( @@ -179,14 +183,17 @@ class MarkdownInlineUpstreamSyntaxReaction implements EditReaction { // were removed. ChangeSelectionRequest( DocumentSelection.collapsed( - position: DocumentPosition( - nodeId: editedNode.id, - nodePosition: TextNodePosition(offset: markdownRun.start + markdownRun.replacementText.length), - ), + position: newCaretPosition, ), SelectionChangeType.alteredContent, SelectionReason.contentChange, ), + ChangeComposingRegionRequest( + DocumentRange( + start: newCaretPosition, + end: newCaretPosition, + ), + ), ]; } } diff --git a/super_editor_markdown/test/custom_parsers/upsell_block.dart b/super_editor_markdown/test/custom_parsers/upsell_block.dart index 129e97be5..1acd11d4d 100644 --- a/super_editor_markdown/test/custom_parsers/upsell_block.dart +++ b/super_editor_markdown/test/custom_parsers/upsell_block.dart @@ -23,6 +23,11 @@ class UpsellNode extends BlockNode with ChangeNotifier { String? copyContent(NodeSelection selection) { return null; } + + @override + DocumentNode copy() { + return UpsellNode(id); + } } /// Markdown block-parser for upsell messages. diff --git a/super_editor_markdown/test/super_editor_markdown_pasting_test.dart b/super_editor_markdown/test/super_editor_markdown_pasting_test.dart index 8d1fbab97..ad1f4266e 100644 --- a/super_editor_markdown/test/super_editor_markdown_pasting_test.dart +++ b/super_editor_markdown/test/super_editor_markdown_pasting_test.dart @@ -353,7 +353,7 @@ Aenean mattis ante justo, quis sollicitudin metus interdum id.''', }); testWidgetsOnMac("can paste a link", (tester) async { - final (_, document, composer) = await _pumpSuperEditor( + final (_, document, _) = await _pumpSuperEditor( tester, deserializeMarkdownToDocument(""), );