Repository: superlistapp/super_editor

Issue:
[Bug][SuperEditor] - Don't copy placeholder characters when creating AttributedText copies
It was discovered that in at least one command, we're copying and adjusting an `AttributedText`, passing in an original `AttributedText`'s plain text, and including the placeholders in that text. This results in repeatedly adding more and more and more placeholders to the text.

The offending command is here:
https://github.com/superlistapp/super_editor/blob/b12692ced4512107993a3f53a562e4376e9a9598/super_editor/lib/src/default_editor/text.dart#L1593

Look for any other such commands and adjust them all to exclude placeholders, or use a different copy mechanism that internally excludes placeholders.



Fix patch:
diff --git a/super_editor/lib/src/default_editor/text.dart b/super_editor/lib/src/default_editor/text.dart
index 879a71977..5d135f16f 100644
--- a/super_editor/lib/src/default_editor/text.dart
+++ b/super_editor/lib/src/default_editor/text.dart
@@ -1590,7 +1590,11 @@ class AddTextAttributionsCommand extends EditCommand {
           node.id,
           node.copyTextNodeWith(
             text: AttributedText(
-              node.text.toPlainText(),
+              node.text.toPlainText(
+                // Don't include placeholder characters, because we're providing
+                // actual placeholders down below.
+                includePlaceholders: false,
+              ),
               node.text.spans.copy()
                 ..addAttribution(
                   newAttribution: attribution,
@@ -1718,13 +1722,18 @@ class RemoveTextAttributionsCommand extends EditCommand {
         // see that we made a change, and re-renders the text in the document.
         node = node.copyTextNodeWith(
           text: AttributedText(
-            node.text.toPlainText(),
+            node.text.toPlainText(
+              // Don't include placeholder characters, because we're providing
+              // actual placeholders down below.
+              includePlaceholders: false,
+            ),
             node.text.spans.copy()
               ..removeAttribution(
                 attributionToRemove: attribution,
                 start: range.start,
                 end: range.end,
               ),
+            Map.from(node.text.placeholders),
           ),
         );
 


Test patch:
diff --git a/super_editor/test/super_editor/supereditor_attributions_test.dart b/super_editor/test/super_editor/supereditor_attributions_test.dart
index 4aee07f9d..105509788 100644
--- a/super_editor/test/super_editor/supereditor_attributions_test.dart
+++ b/super_editor/test/super_editor/supereditor_attributions_test.dart
@@ -10,8 +10,8 @@ import 'supereditor_test_tools.dart';
 import 'test_documents.dart';
 
 void main() {
-  group("SuperEditor", () {
-    group("applies attributions", () {
+  group("SuperEditor >", () {
+    group("applies attributions >", () {
       group("when continuing existing attributions >", () {
         group("by placing caret >", () {
           // Bold is a stand-in for any value-based attribution extension, e.g.,
@@ -1703,6 +1703,96 @@ void main() {
         ),
       );
     });
+
+    testWidgetsOnArbitraryDesktop('adds and removes attributions with placeholders', (tester) async {
+      // A test that locks down a fix for bug: https://github.com/superlistapp/super_editor/issues/2776
+      // The problem was a behavior in the command that was incorrectly copying the `AttributedText`
+      // when the `AttributedText` has placeholders.
+      final testContext = await tester //
+          .createDocument()
+          .withCustomContent(
+            MutableDocument(
+              nodes: [
+                ParagraphNode(
+                  id: '1',
+                  text: AttributedText(
+                    'this [] orange [] that',
+                    null,
+                    {
+                      6: 'first',
+                      17: 'second',
+                    },
+                  ),
+                )
+              ],
+            ),
+          )
+          .pump();
+
+      // Apply orange color to the word "orange".
+      testContext.editor.execute([
+        AddTextAttributionsRequest(
+          documentRange: _creatSingleNodeTextRange('1', 5, 11),
+          attributions: {const ColorAttribution(Colors.orange)},
+        ),
+      ]);
+      await tester.pump();
+
+      // Ensure the plain text value didn't change, the attribution was applied, and the placeholders remain.
+      expect(
+        SuperEditorInspector.findTextInComponent('1').toPlainText(includePlaceholders: false),
+        'this [] orange [] that',
+      );
+
+      expect(
+        SuperEditorInspector.findTextInComponent('1').spans,
+        AttributedSpans(
+          attributions: [
+            ..._createSpanMarkersForAttribution(
+              attribution: const ColorAttribution(Colors.orange),
+              startOffset: 5,
+              endOffset: 10,
+            ),
+          ],
+        ),
+      );
+
+      expect(
+        SuperEditorInspector.findTextInComponent('1').placeholders,
+        {
+          6: 'first',
+          17: 'second',
+        },
+      );
+
+      // Now test the removal command by removing the attribution.
+      testContext.editor.execute([
+        RemoveTextAttributionsRequest(
+          documentRange: _creatSingleNodeTextRange('1', 5, 11),
+          attributions: {const ColorAttribution(Colors.orange)},
+        ),
+      ]);
+      await tester.pump();
+
+      // Ensure the plain text value didn't change, the attribution was removed, and the placeholders remain.
+      expect(
+        SuperEditorInspector.findTextInComponent('1').toPlainText(includePlaceholders: false),
+        'this [] orange [] that',
+      );
+
+      expect(
+        SuperEditorInspector.findTextInComponent('1').spans,
+        AttributedSpans(),
+      );
+
+      expect(
+        SuperEditorInspector.findTextInComponent('1').placeholders,
+        {
+          6: 'first',
+          17: 'second',
+        },
+      );
+    });
   });
 }