Skip to content

Commit ba4612c

Browse files
committed
feat: length override for custom embeds
1 parent e14689b commit ba4612c

File tree

5 files changed

+540
-10
lines changed

5 files changed

+540
-10
lines changed

lib/src/controller/quill_controller.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../document/nodes/leaf.dart';
1616
import '../document/structs/doc_change.dart';
1717
import '../document/style.dart';
1818
import '../editor/config/editor_config.dart';
19+
import '../editor/raw_editor/offset_mapping.dart';
1920
import '../editor/raw_editor/raw_editor_state.dart';
2021
import '../editor_toolbar_controller_shared/clipboard/clipboard_service_provider.dart';
2122
import 'clipboard/quill_controller_paste.dart';
@@ -81,6 +82,7 @@ class QuillController extends ChangeNotifier {
8182
set document(Document doc) {
8283
_document = doc;
8384
_setDocumentSearchProperties();
85+
_cachedOffsetMapping = null;
8486

8587
// Prevent the selection from
8688
_selection = const TextSelection(baseOffset: 0, extentOffset: 0);
@@ -137,6 +139,36 @@ class QuillController extends ChangeNotifier {
137139
selection: selection,
138140
);
139141

142+
/// Cached offset mapping between document offsets and expanded-text offsets.
143+
OffsetMapping? _cachedOffsetMapping;
144+
145+
/// Returns the [OffsetMapping] for the current document state.
146+
///
147+
/// The mapping is lazily built and cached; it is invalidated automatically
148+
/// whenever the controller notifies listeners (i.e. on every document or
149+
/// selection change).
150+
OffsetMapping get offsetMapping {
151+
return _cachedOffsetMapping ??= buildOffsetMapping(
152+
document.toDelta(),
153+
_editorConfig?.embedBuilders,
154+
_editorConfig?.unknownEmbedBuilder,
155+
);
156+
}
157+
158+
/// A [TextEditingValue] with embeds expanded to their [EmbedBuilder.toPlainText]
159+
/// representation and the selection mapped to expanded-text offsets.
160+
///
161+
/// This is sent to the platform so the OS keyboard can detect word and
162+
/// sentence boundaries around inline embeds (e.g. for
163+
/// [TextCapitalization.sentences]).
164+
TextEditingValue get expandedTextEditingValue {
165+
final mapping = offsetMapping;
166+
return TextEditingValue(
167+
text: mapping.expandedText,
168+
selection: mapping.docToExpandedSelection(selection),
169+
);
170+
}
171+
140172
/// Only attributes applied to all characters within this range are
141173
/// included in the result.
142174
Style getSelectionStyle() {
@@ -433,6 +465,12 @@ class QuillController extends ChangeNotifier {
433465
notifyListeners();
434466
}
435467

468+
@override
469+
void notifyListeners() {
470+
_cachedOffsetMapping = null;
471+
super.notifyListeners();
472+
}
473+
436474
@override
437475
void addListener(VoidCallback listener) {
438476
// By using `_isDisposed`, make sure that `addListener` won't be called on a

lib/src/editor/embed/embed_editor_builder.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ abstract class EmbedBuilder {
1111
String get key;
1212
bool get expanded => true;
1313

14+
/// The character length this embed occupies in the text sent to the platform.
15+
///
16+
/// Defaults to `1` (the single object-replacement character `\uFFFC`).
17+
/// Override this together with [toPlainText] to return a value matching
18+
/// the length of the plain-text representation so that the OS keyboard
19+
/// can correctly detect word and sentence boundaries around the embed
20+
/// (e.g. for [TextCapitalization.sentences]).
21+
int get length => 1;
22+
1423
WidgetSpan buildWidgetSpan(Widget widget) {
1524
return WidgetSpan(child: widget);
1625
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import 'package:flutter/services.dart' show TextSelection, TextRange;
2+
3+
import '../../../quill_delta.dart';
4+
import '../../document/nodes/embeddable.dart';
5+
import '../../document/nodes/leaf.dart';
6+
import '../embed/embed_editor_builder.dart';
7+
8+
/// Records the position and expanded length of a single embed
9+
/// whose `toPlainText()` returns a string longer than 1 character.
10+
class EmbedSpan {
11+
const EmbedSpan({
12+
required this.docOffset,
13+
required this.expandedOffset,
14+
required this.expandedLength,
15+
});
16+
17+
/// Offset of this embed in the document model (where it is length 1).
18+
final int docOffset;
19+
20+
/// Offset of this embed in the expanded text.
21+
final int expandedOffset;
22+
23+
/// Length of the embed's text in expanded form.
24+
final int expandedLength;
25+
}
26+
27+
/// Bidirectional mapping between document offsets (where embeds are length 1)
28+
/// and expanded-text offsets (where embeds use their `toPlainText()` length).
29+
///
30+
/// Used exclusively at the platform text-input boundary so that the OS
31+
/// keyboard sees real text for sentence/word boundary detection.
32+
class OffsetMapping {
33+
OffsetMapping._({
34+
required this.expandedText,
35+
required List<EmbedSpan> embeds,
36+
}) : _embeds = embeds;
37+
38+
/// The plain text with embeds expanded to their `toPlainText()` value.
39+
final String expandedText;
40+
41+
/// Embed spans sorted by [EmbedSpan.docOffset].
42+
final List<EmbedSpan> _embeds;
43+
44+
/// Convert a document offset to the corresponding expanded-text offset.
45+
int docToExpanded(int docOffset) {
46+
var shift = 0;
47+
for (final embed in _embeds) {
48+
if (embed.docOffset < docOffset) {
49+
shift += embed.expandedLength - 1;
50+
} else {
51+
break;
52+
}
53+
}
54+
return docOffset + shift;
55+
}
56+
57+
/// Convert an expanded-text offset to a document offset.
58+
///
59+
/// If the offset falls inside an embed's expanded text, snaps to the
60+
/// nearest boundary (start or end of the embed in document space).
61+
int expandedToDoc(int expandedOffset) {
62+
var shift = 0;
63+
for (final embed in _embeds) {
64+
final embedStart = embed.expandedOffset;
65+
final embedEnd = embed.expandedOffset + embed.expandedLength;
66+
67+
if (expandedOffset <= embedStart) {
68+
break;
69+
} else if (expandedOffset < embedEnd) {
70+
// Inside the embed — snap to nearest boundary.
71+
final distToStart = expandedOffset - embedStart;
72+
final distToEnd = embedEnd - expandedOffset;
73+
if (distToStart <= distToEnd) {
74+
return embed.docOffset; // before embed
75+
} else {
76+
return embed.docOffset + 1; // after embed
77+
}
78+
} else {
79+
shift += embed.expandedLength - 1;
80+
}
81+
}
82+
return expandedOffset - shift;
83+
}
84+
85+
/// Convert an expanded-text offset to a document offset.
86+
///
87+
/// If the offset falls inside an embed's expanded text, snaps to the
88+
/// **start** of the embed in document space.
89+
/// Use this for the start of a deletion range.
90+
int expandedToDocFloor(int expandedOffset) {
91+
var shift = 0;
92+
for (final embed in _embeds) {
93+
final embedStart = embed.expandedOffset;
94+
final embedEnd = embed.expandedOffset + embed.expandedLength;
95+
96+
if (expandedOffset <= embedStart) {
97+
break;
98+
} else if (expandedOffset < embedEnd) {
99+
return embed.docOffset;
100+
} else {
101+
shift += embed.expandedLength - 1;
102+
}
103+
}
104+
return expandedOffset - shift;
105+
}
106+
107+
/// Convert an expanded-text offset to a document offset.
108+
///
109+
/// If the offset falls inside an embed's expanded text, snaps to the
110+
/// **end** (one past) the embed in document space.
111+
/// Use this for the end of a deletion range.
112+
int expandedToDocCeil(int expandedOffset) {
113+
var shift = 0;
114+
for (final embed in _embeds) {
115+
final embedStart = embed.expandedOffset;
116+
final embedEnd = embed.expandedOffset + embed.expandedLength;
117+
118+
if (expandedOffset <= embedStart) {
119+
break;
120+
} else if (expandedOffset <= embedEnd) {
121+
// At or inside the embed — snap to after the embed.
122+
return embed.docOffset + 1;
123+
} else {
124+
shift += embed.expandedLength - 1;
125+
}
126+
}
127+
return expandedOffset - shift;
128+
}
129+
130+
/// Convert a document-space [TextSelection] to expanded-text space.
131+
TextSelection docToExpandedSelection(TextSelection selection) {
132+
return selection.copyWith(
133+
baseOffset: docToExpanded(selection.baseOffset),
134+
extentOffset: docToExpanded(selection.extentOffset),
135+
);
136+
}
137+
138+
/// Convert an expanded-text-space [TextSelection] to document space.
139+
TextSelection expandedToDocSelection(TextSelection selection) {
140+
return selection.copyWith(
141+
baseOffset: expandedToDoc(selection.baseOffset),
142+
extentOffset: expandedToDoc(selection.extentOffset),
143+
);
144+
}
145+
146+
/// Convert an expanded-text-space [TextRange] to document space.
147+
TextRange expandedToDocRange(TextRange range) {
148+
return TextRange(
149+
start: expandedToDoc(range.start),
150+
end: expandedToDoc(range.end),
151+
);
152+
}
153+
}
154+
155+
/// Build an [OffsetMapping] by walking the document [delta].
156+
///
157+
/// For each embed operation, looks up the matching [EmbedBuilder] from
158+
/// [embedBuilders] (or [unknownEmbedBuilder]) and calls `toPlainText()`.
159+
/// If the expanded text differs from the single-character default, an
160+
/// [EmbedSpan] is recorded.
161+
OffsetMapping buildOffsetMapping(
162+
Delta delta,
163+
Iterable<EmbedBuilder>? embedBuilders,
164+
EmbedBuilder? unknownEmbedBuilder,
165+
) {
166+
final buffer = StringBuffer();
167+
final embeds = <EmbedSpan>[];
168+
var docOffset = 0;
169+
var expandedOffset = 0;
170+
171+
for (final op in delta.toList()) {
172+
if (!op.isInsert) continue;
173+
174+
if (op.data is Map) {
175+
// Embed operation.
176+
final embeddable =
177+
Embeddable.fromJson(Map<String, dynamic>.from(op.data as Map));
178+
final embedText = _getEmbedPlainText(
179+
embeddable,
180+
embedBuilders,
181+
unknownEmbedBuilder,
182+
);
183+
184+
buffer.write(embedText);
185+
186+
if (embedText.length != 1 ||
187+
embedText != Embed.kObjectReplacementCharacter) {
188+
embeds.add(EmbedSpan(
189+
docOffset: docOffset,
190+
expandedOffset: expandedOffset,
191+
expandedLength: embedText.length,
192+
));
193+
}
194+
195+
docOffset += 1; // always 1 in document/delta
196+
expandedOffset += embedText.length;
197+
} else {
198+
// Text operation.
199+
final text = op.data as String;
200+
buffer.write(text);
201+
docOffset += text.length;
202+
expandedOffset += text.length;
203+
}
204+
}
205+
206+
return OffsetMapping._(
207+
expandedText: buffer.toString(),
208+
embeds: embeds,
209+
);
210+
}
211+
212+
/// Look up the matching [EmbedBuilder] for [embeddable] and call
213+
/// `toPlainText()`. Falls back to [unknownEmbedBuilder] or `\uFFFC`.
214+
String _getEmbedPlainText(
215+
Embeddable embeddable,
216+
Iterable<EmbedBuilder>? embedBuilders,
217+
EmbedBuilder? unknownEmbedBuilder,
218+
) {
219+
final embedNode = Embed(embeddable);
220+
221+
if (embedBuilders != null) {
222+
for (final builder in embedBuilders) {
223+
if (builder.key == embeddable.type) {
224+
final text = builder.toPlainText(embedNode);
225+
// Enforce non-empty — fall back to replacement character.
226+
return text.isEmpty ? Embed.kObjectReplacementCharacter : text;
227+
}
228+
}
229+
}
230+
231+
if (unknownEmbedBuilder != null) {
232+
final text = unknownEmbedBuilder.toPlainText(embedNode);
233+
return text.isEmpty ? Embed.kObjectReplacementCharacter : text;
234+
}
235+
236+
return Embed.kObjectReplacementCharacter;
237+
}

0 commit comments

Comments
 (0)