Skip to content

Commit cf3ebc4

Browse files
[SuperEditor] - Fix text duplicated when a placeholder is inserted in a text with attributions (Resolves #2595) (#2605)
1 parent 165999a commit cf3ebc4

File tree

2 files changed

+236
-52
lines changed

2 files changed

+236
-52
lines changed

super_editor/lib/src/infrastructure/attributed_text_styles.dart

Lines changed: 45 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ extension ComputeTextSpan on AttributedText {
1919
/// The given [styleBuilder] interprets the meaning of every attribution
2020
/// and constructs [TextStyle]s accordingly.
2121
///
22-
/// The given [inlineWidgetBuilder] interprets every placeholder `Object`
22+
/// The given [inlineWidgetBuilders] interprets every placeholder `Object`
2323
/// and builds a corresponding inline widget.
2424
InlineSpan computeInlineSpan(
2525
BuildContext context,
@@ -34,67 +34,60 @@ extension ComputeTextSpan on AttributedText {
3434
final inlineSpans = <InlineSpan>[];
3535

3636
final collapsedSpans = spans.collapseSpans(contentLength: length);
37-
var spanIndex = 0;
38-
var span = collapsedSpans.first;
39-
40-
int start = 0;
41-
while (start < length) {
42-
late int contentEnd;
43-
if (placeholders[start] != null) {
44-
// This section is a placeholder.
45-
contentEnd = start + 1;
46-
47-
final textStyle = styleBuilder({});
48-
Widget? inlineWidget;
49-
for (final builder in inlineWidgetBuilders) {
50-
inlineWidget = builder(context, textStyle, placeholders[start]!);
51-
if (inlineWidget != null) {
52-
break;
37+
38+
for (final span in collapsedSpans) {
39+
final textStyle = styleBuilder(span.attributions);
40+
41+
// A single span might be divided in multiple inline spans if there are placeholders.
42+
// Keep track of the start of the current inline span.
43+
int startOfMostRecentTextRun = span.start;
44+
45+
// Look for placeholders within the current span and split the span accordingly.
46+
for (int i = span.start; i <= span.end; i++) {
47+
if (placeholders[i] != null) {
48+
// We found a placeholder. Build a widget for it.
49+
50+
if (i > startOfMostRecentTextRun) {
51+
// There is text before the placeholder. Add the current text run to the span.
52+
inlineSpans.add(
53+
TextSpan(
54+
text: substring(startOfMostRecentTextRun, i),
55+
style: textStyle,
56+
),
57+
);
5358
}
54-
}
5559

56-
if (inlineWidget != null) {
57-
inlineSpans.add(
58-
_LayoutOptimizedWidgetSpan(
59-
alignment: PlaceholderAlignment.middle,
60-
child: inlineWidget,
61-
),
62-
);
63-
}
64-
} else {
65-
// This section is text. The end of this text is either the
66-
// end of the AttributedText, or the index of the next placeholder.
67-
contentEnd = span.end + 1;
68-
for (final entry in placeholders.entries) {
69-
if (entry.key > start) {
70-
contentEnd = entry.key;
71-
break;
60+
Widget? inlineWidget;
61+
for (final builder in inlineWidgetBuilders) {
62+
inlineWidget = builder(context, textStyle, placeholders[i]!);
63+
if (inlineWidget != null) {
64+
break;
65+
}
66+
}
67+
68+
if (inlineWidget != null) {
69+
inlineSpans.add(
70+
_LayoutOptimizedWidgetSpan(
71+
alignment: PlaceholderAlignment.middle,
72+
child: inlineWidget,
73+
),
74+
);
7275
}
76+
77+
// Start another inline span after the placeholder.
78+
startOfMostRecentTextRun = i + 1;
7379
}
80+
}
7481

82+
if (startOfMostRecentTextRun <= span.end) {
83+
// There is text after the last placeholder or there is no placeholder at all.
7584
inlineSpans.add(
7685
TextSpan(
77-
text: substring(start, contentEnd),
78-
style: styleBuilder(span.attributions),
86+
text: substring(startOfMostRecentTextRun, span.end + 1),
87+
style: textStyle,
7988
),
8089
);
8190
}
82-
83-
if (contentEnd == span.end + 1) {
84-
// The content and span end at the same place.
85-
start = contentEnd;
86-
} else if (contentEnd < span.end + 1) {
87-
// The content ends before the span.
88-
start = contentEnd;
89-
} else {
90-
// The span ends before the content.
91-
start = span.end + 1;
92-
}
93-
94-
if (start > span.end && start < length) {
95-
spanIndex += 1;
96-
span = collapsedSpans[spanIndex];
97-
}
9891
}
9992

10093
return TextSpan(
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:super_editor/super_editor.dart';
4+
5+
void main() {
6+
group('SuperEditor > computeInlineSpan >', () {
7+
testWidgets('computes inlineSpan for text with attributions and a placeholder at the beginning', (tester) async {
8+
// Pump a widget because we need a BuildContext to compute the InlineSpan.
9+
await tester.pumpWidget(
10+
const MaterialApp(),
11+
);
12+
13+
// Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and with a leading placeholder.
14+
final text = AttributedText(
15+
'Welcome to SuperEditor',
16+
AttributedSpans(
17+
attributions: [
18+
const SpanMarker(attribution: boldAttribution, offset: 1, markerType: SpanMarkerType.start),
19+
const SpanMarker(attribution: boldAttribution, offset: 7, markerType: SpanMarkerType.end),
20+
const SpanMarker(attribution: boldAttribution, offset: 12, markerType: SpanMarkerType.start),
21+
const SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end),
22+
],
23+
),
24+
{0: const _ExamplePlaceholder()},
25+
);
26+
27+
final inlineSpan = text.computeInlineSpan(
28+
find.byType(MaterialApp).evaluate().first as BuildContext,
29+
defaultStyleBuilder,
30+
[_inlineWidgetBuilder],
31+
);
32+
33+
final spanList = _flattenInlineSpan(inlineSpan);
34+
expect(spanList.length, equals(5));
35+
36+
// Ensure that the first span is an empty TextSpan with the default fontWeight.
37+
expect(spanList[0], isA<TextSpan>());
38+
expect((spanList[0] as TextSpan).text, equals(''));
39+
expect((spanList[0] as TextSpan).style!.fontWeight, isNull);
40+
41+
// Expect that the second span is the widget rendered using the placeholder.
42+
expect(spanList[1], isA<WidgetSpan>());
43+
44+
// Ensure that the third span is a TextSpan with the text "Welcome" in bold.
45+
expect(spanList[2], isA<TextSpan>());
46+
expect((spanList[2] as TextSpan).text, equals('Welcome'));
47+
expect((spanList[2] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
48+
49+
// Ensure that the fourth span is a TextSpan with the text " to " with the default fontWeight.
50+
expect(spanList[3], isA<TextSpan>());
51+
expect((spanList[3] as TextSpan).text, equals(' to '));
52+
expect((spanList[3] as TextSpan).style!.fontWeight, isNull);
53+
54+
// Ensure that the fifth span is a TextSpan with the text "SuperEditor" in bold.
55+
expect(spanList[4], isA<TextSpan>());
56+
expect((spanList[4] as TextSpan).text, equals('SuperEditor'));
57+
expect((spanList[4] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
58+
});
59+
60+
testWidgets('computes inlineSpan for text with attributions and a placeholder at the middle', (tester) async {
61+
// Pump a widget because we need a BuildContext to compute the InlineSpan.
62+
await tester.pumpWidget(
63+
const MaterialApp(),
64+
);
65+
66+
// Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and with a
67+
// placeholder after the word "to".
68+
final text = AttributedText(
69+
'Welcome to SuperEditor',
70+
AttributedSpans(
71+
attributions: [
72+
const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start),
73+
const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end),
74+
const SpanMarker(attribution: boldAttribution, offset: 12, markerType: SpanMarkerType.start),
75+
const SpanMarker(attribution: boldAttribution, offset: 22, markerType: SpanMarkerType.end),
76+
],
77+
),
78+
{10: const _ExamplePlaceholder()},
79+
);
80+
81+
final inlineSpan = text.computeInlineSpan(
82+
find.byType(MaterialApp).evaluate().first as BuildContext,
83+
defaultStyleBuilder,
84+
[_inlineWidgetBuilder],
85+
);
86+
87+
final spanList = _flattenInlineSpan(inlineSpan);
88+
expect(spanList.length, equals(6));
89+
90+
// Ensure that the first span is an empty TextSpan with the default fontWeight.
91+
expect(spanList[0], isA<TextSpan>());
92+
expect((spanList[0] as TextSpan).text, equals(''));
93+
expect((spanList[0] as TextSpan).style!.fontWeight, isNull);
94+
95+
// Expect that the second span is a TextSpan with the text "Welcome" in bold.
96+
expect(spanList[1], isA<TextSpan>());
97+
expect((spanList[1] as TextSpan).text, equals('Welcome'));
98+
expect((spanList[1] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
99+
100+
// Ensure that the third span is a TextSpan with the text " to" with the default fontWeight.
101+
expect(spanList[2], isA<TextSpan>());
102+
expect((spanList[2] as TextSpan).text, equals(' to'));
103+
expect((spanList[2] as TextSpan).style!.fontWeight, isNull);
104+
105+
// Expect that the fourth span is the widget rendered using the placeholder.
106+
expect(spanList[3], isA<WidgetSpan>());
107+
108+
// Ensure that the fifth span is a TextSpan with the text " " with the default fontWeight.
109+
expect(spanList[4], isA<TextSpan>());
110+
expect((spanList[4] as TextSpan).text, equals(' '));
111+
expect((spanList[4] as TextSpan).style!.fontWeight, isNull);
112+
113+
// Ensure that the sixth span is a TextSpan with the text "SuperEditor" in bold.
114+
expect(spanList[5], isA<TextSpan>());
115+
expect((spanList[5] as TextSpan).text, equals('SuperEditor'));
116+
expect((spanList[5] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
117+
});
118+
119+
testWidgets('computes inlineSpan for text with attributions and a placeholder at the end', (tester) async {
120+
// Pump a widget because we need a BuildContext to compute the InlineSpan.
121+
await tester.pumpWidget(
122+
const MaterialApp(),
123+
);
124+
125+
// Create an AttributedText with the words "Welcome" and "SuperEditor" in bold and a trailing placeholder.
126+
final text = AttributedText(
127+
'Welcome to SuperEditor',
128+
AttributedSpans(
129+
attributions: [
130+
const SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start),
131+
const SpanMarker(attribution: boldAttribution, offset: 6, markerType: SpanMarkerType.end),
132+
const SpanMarker(attribution: boldAttribution, offset: 11, markerType: SpanMarkerType.start),
133+
const SpanMarker(attribution: boldAttribution, offset: 21, markerType: SpanMarkerType.end),
134+
],
135+
),
136+
{22: const _ExamplePlaceholder()},
137+
);
138+
139+
final inlineSpan = text.computeInlineSpan(
140+
find.byType(MaterialApp).evaluate().first as BuildContext,
141+
defaultStyleBuilder,
142+
[_inlineWidgetBuilder],
143+
);
144+
145+
final spanList = _flattenInlineSpan(inlineSpan);
146+
expect(spanList.length, equals(5));
147+
148+
// Ensure that the first span is an empty TextSpan with the default fontWeight.
149+
expect(spanList[0], isA<TextSpan>());
150+
expect((spanList[0] as TextSpan).text, equals(''));
151+
expect((spanList[0] as TextSpan).style!.fontWeight, isNull);
152+
153+
// Ensure that the second span is a TextSpan with the text "Welcome" in bold.
154+
expect(spanList[1], isA<TextSpan>());
155+
expect((spanList[1] as TextSpan).text, equals('Welcome'));
156+
expect((spanList[1] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
157+
158+
// Ensure that the third span is a TextSpan with the text " to " with the default fontWeight.
159+
expect(spanList[2], isA<TextSpan>());
160+
expect((spanList[2] as TextSpan).text, equals(' to '));
161+
expect((spanList[2] as TextSpan).style!.fontWeight, isNull);
162+
163+
// Ensure that the fourth span is a TextSpan with the text "SuperEditor" in bold.
164+
expect(spanList[3], isA<TextSpan>());
165+
expect((spanList[3] as TextSpan).text, equals('SuperEditor'));
166+
expect((spanList[3] as TextSpan).style!.fontWeight, equals(FontWeight.bold));
167+
168+
// Expect that the fifth span is the widget rendered using the placeholder.
169+
expect(spanList[4], isA<WidgetSpan>());
170+
});
171+
});
172+
}
173+
174+
List<InlineSpan> _flattenInlineSpan(InlineSpan inlineSpan) {
175+
final flatList = <InlineSpan>[];
176+
177+
inlineSpan.visitChildren((child) {
178+
flatList.add(child);
179+
return true;
180+
});
181+
182+
return flatList;
183+
}
184+
185+
class _ExamplePlaceholder {
186+
const _ExamplePlaceholder();
187+
}
188+
189+
Widget? _inlineWidgetBuilder(BuildContext context, TextStyle textStyle, Object placeholder) {
190+
return const SizedBox(width: 10);
191+
}

0 commit comments

Comments
 (0)