Skip to content

Commit 0051f48

Browse files
[Super Editor] - Fix: applying styles to text with inline placeholders deletes the placeholders (Resolves #2555) (#2556)
1 parent c3106bb commit 0051f48

File tree

3 files changed

+257
-14
lines changed

3 files changed

+257
-14
lines changed

super_editor/lib/src/default_editor/default_document_editor.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ final defaultRequestHandlers = List.unmodifiable(<EditRequestHandler>[
6161
(editor, request) => request is RemoveComposerPreferenceStylesRequest //
6262
? RemoveComposerPreferenceStylesCommand(request.stylesToRemove)
6363
: null,
64+
(editor, request) => request is InsertStyledTextAtCaretRequest //
65+
? InsertStyledTextAtCaretCommand(request.text)
66+
: null,
67+
(editor, request) => request is InsertInlinePlaceholderAtCaretRequest //
68+
? InsertInlinePlaceholderAtCaretCommand(request.placeholder)
69+
: null,
6470
(editor, request) => request is InsertTextRequest
6571
? InsertTextCommand(
6672
documentPosition: request.documentPosition,

super_editor/lib/src/default_editor/text.dart

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,10 +1779,8 @@ class ToggleTextAttributionsCommand extends EditCommand {
17791779
// Create a new AttributedText with updated attribution spans, so that the presentation system can
17801780
// see that we made a change, and re-renders the text in the document.
17811781
node = node.copyTextNodeWith(
1782-
text: AttributedText(
1783-
node.text.toPlainText(),
1784-
node.text.spans.copy(),
1785-
)..removeAttribution(
1782+
text: node.text.copy() //
1783+
..removeAttribution(
17861784
attribution,
17871785
range,
17881786
),
@@ -1794,16 +1792,17 @@ class ToggleTextAttributionsCommand extends EditCommand {
17941792
// Create a new AttributedText with updated attribution spans, so that the presentation system can
17951793
// see that we made a change, and re-renders the text in the document.
17961794
node = node.copyTextNodeWith(
1797-
text: AttributedText(
1798-
node.text.toPlainText(),
1799-
node.text.spans.copy()
1800-
..addAttribution(
1801-
newAttribution: attribution,
1802-
start: range.start,
1803-
end: range.end,
1804-
autoMerge: true,
1805-
),
1806-
),
1795+
text: node.text.copy() //
1796+
..addAttribution(
1797+
attribution,
1798+
range,
1799+
autoMerge: true,
1800+
// FIXME: I noticed that the default value for overwriteConflictingSpans on
1801+
// AttributedText.addAttribution is `false`, but the default on AttributedSpans.addAttribution()
1802+
// is `true`. This seems like a likely bug. Should they actually be different? If not,
1803+
// update one of them. If so, add a comment to both places mentioning why.
1804+
overwriteConflictingSpans: true,
1805+
),
18071806
);
18081807
}
18091808

@@ -2553,6 +2552,77 @@ class InsertAttributedTextCommand extends EditCommand {
25532552
}
25542553
}
25552554

2555+
class InsertStyledTextAtCaretRequest implements EditRequest {
2556+
const InsertStyledTextAtCaretRequest(this.text);
2557+
2558+
final AttributedText text;
2559+
}
2560+
2561+
class InsertStyledTextAtCaretCommand extends EditCommand {
2562+
const InsertStyledTextAtCaretCommand(this.text);
2563+
2564+
final AttributedText text;
2565+
2566+
@override
2567+
void execute(EditContext context, CommandExecutor executor) {
2568+
final selection = context.composer.selection;
2569+
if (selection == null) {
2570+
// Can't insert at caret if there is no caret.
2571+
return;
2572+
}
2573+
if (!selection.isCollapsed) {
2574+
// The selection is expanded. There's no caret. Fizzle.
2575+
// Maybe we want these commands to actually be "at selection" instead of
2576+
// "at caret" and then delete the selected content.
2577+
return;
2578+
}
2579+
2580+
executor
2581+
..executeCommand(
2582+
InsertAttributedTextCommand(
2583+
documentPosition: selection.extent,
2584+
textToInsert: text,
2585+
),
2586+
)
2587+
..executeCommand(
2588+
ChangeSelectionCommand(
2589+
DocumentSelection.collapsed(
2590+
position: selection.extent.copyWith(
2591+
nodePosition: TextNodePosition(
2592+
offset: (selection.extent.nodePosition as TextNodePosition).offset + text.length,
2593+
),
2594+
),
2595+
),
2596+
SelectionChangeType.insertContent,
2597+
SelectionReason.userInteraction,
2598+
),
2599+
);
2600+
}
2601+
}
2602+
2603+
class InsertInlinePlaceholderAtCaretRequest implements EditRequest {
2604+
const InsertInlinePlaceholderAtCaretRequest(this.placeholder);
2605+
2606+
final Object placeholder;
2607+
}
2608+
2609+
class InsertInlinePlaceholderAtCaretCommand extends EditCommand {
2610+
const InsertInlinePlaceholderAtCaretCommand(this.placeholder);
2611+
2612+
final Object placeholder;
2613+
2614+
@override
2615+
void execute(EditContext context, CommandExecutor executor) {
2616+
executor.executeCommand(
2617+
InsertStyledTextAtCaretCommand(
2618+
AttributedText("", null, {
2619+
0: placeholder,
2620+
}),
2621+
),
2622+
);
2623+
}
2624+
}
2625+
25562626
ExecutionInstruction anyCharacterToInsertInTextContent({
25572627
required SuperEditorContext editContext,
25582628
required KeyEvent keyEvent,
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:flutter_test_robots/flutter_test_robots.dart';
4+
import 'package:flutter_test_runners/flutter_test_runners.dart';
5+
import 'package:super_editor/src/test/super_editor_test/supereditor_inspector.dart';
6+
import 'package:super_editor/src/test/super_editor_test/supereditor_robot.dart';
7+
import 'package:super_editor/super_editor.dart';
8+
9+
import '../supereditor_test_tools.dart';
10+
11+
void main() {
12+
group("Super Editor > inline widgets >", () {
13+
testWidgetsOnArbitraryDesktop("can insert an inline widget in the middle of typing", (tester) async {
14+
final editor = await _pumpScaffold(tester);
15+
16+
await tester.typeImeText("Hello ");
17+
editor.execute([
18+
const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()),
19+
]);
20+
await tester.typeImeText(" inline widgets");
21+
22+
expect(
23+
SuperEditorInspector.findTextInComponent("1"),
24+
AttributedText(
25+
"Hello inline widgets",
26+
null,
27+
{
28+
6: const _TestPlaceholder(),
29+
},
30+
),
31+
);
32+
});
33+
34+
testWidgetsOnArbitraryDesktop("can backspace delete an inline placeholder", (tester) async {
35+
final editor = await _pumpScaffold(tester);
36+
37+
// Insert text with an inline placeholder.
38+
await tester.typeImeText("Hello ");
39+
editor.execute([
40+
const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()),
41+
]);
42+
await tester.pump();
43+
44+
// Ensure we inserted the placeholder.
45+
expect(
46+
SuperEditorInspector.findTextInComponent("1"),
47+
AttributedText(
48+
"Hello ",
49+
null,
50+
{
51+
6: const _TestPlaceholder(),
52+
},
53+
),
54+
);
55+
56+
// Backspace to delete the placeholder.
57+
await tester.pressBackspace();
58+
59+
// Ensure the inline placeholder was deleted.
60+
expect(
61+
SuperEditorInspector.findTextInComponent("1"),
62+
AttributedText(
63+
"Hello ",
64+
null,
65+
{},
66+
),
67+
);
68+
});
69+
70+
testWidgetsOnArbitraryDesktop("can select text and apply a style change without losing placeholder",
71+
(tester) async {
72+
final editor = await _pumpScaffold(tester);
73+
74+
// Insert text with an inline placeholder in the middle.
75+
await tester.typeImeText("Hello ");
76+
editor.execute([
77+
const InsertInlinePlaceholderAtCaretRequest(_TestPlaceholder()),
78+
]);
79+
await tester.typeImeText(" inline widgets");
80+
81+
// Select text and also the inline placeholder.
82+
// TODO: Create tester extension to drag and select text on desktop
83+
editor.execute([
84+
const ChangeSelectionRequest(
85+
DocumentSelection(
86+
base: DocumentPosition(
87+
nodeId: "1",
88+
nodePosition: TextNodePosition(offset: 6),
89+
),
90+
extent: DocumentPosition(
91+
nodeId: "1",
92+
nodePosition: TextNodePosition(offset: 14),
93+
),
94+
),
95+
SelectionChangeType.expandSelection,
96+
SelectionReason.userInteraction,
97+
),
98+
]);
99+
await tester.pump();
100+
101+
// Apply bold to the text.
102+
await tester.pressCmdB();
103+
104+
// Ensure the inline placeholder is still there.
105+
expect(
106+
SuperEditorInspector.findTextInComponent("1"),
107+
AttributedText(
108+
"Hello inline widgets",
109+
AttributedSpans(
110+
attributions: const [
111+
SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.start),
112+
SpanMarker(attribution: boldAttribution, offset: 13, markerType: SpanMarkerType.end),
113+
],
114+
),
115+
{
116+
6: const _TestPlaceholder(),
117+
},
118+
),
119+
);
120+
121+
// Un-apply bold to the text.
122+
await tester.pressCmdB();
123+
124+
// Ensure the inline placeholder is still there.
125+
expect(
126+
SuperEditorInspector.findTextInComponent("1"),
127+
AttributedText(
128+
"Hello inline widgets",
129+
null,
130+
{
131+
6: const _TestPlaceholder(),
132+
},
133+
),
134+
);
135+
});
136+
});
137+
}
138+
139+
Future<Editor> _pumpScaffold(WidgetTester tester) async {
140+
final context = await tester
141+
.createDocument()
142+
.withSingleEmptyParagraph()
143+
.withInputSource(TextInputSource.ime)
144+
.useStylesheet(defaultStylesheet.copyWith(
145+
inlineWidgetBuilders: [_buildInlineTestWidget],
146+
))
147+
.autoFocus(true)
148+
.pump();
149+
150+
return context.editor;
151+
}
152+
153+
Widget? _buildInlineTestWidget(BuildContext context, TextStyle style, Object placeholder) {
154+
if (placeholder is! _TestPlaceholder) {
155+
return null;
156+
}
157+
158+
return Container(
159+
width: 16,
160+
height: 16,
161+
color: Colors.black,
162+
);
163+
}
164+
165+
class _TestPlaceholder {
166+
const _TestPlaceholder();
167+
}

0 commit comments

Comments
 (0)