Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Added localization support for `mn` (Mongolian, Mongolia)
- Added `length` property to `EmbedBuilder` and internal offset mapping so custom inline embeds (mentions, hashtags, etc.) can declare their character length for the platform keyboard, fixing `TextCapitalization.sentences` and word/sentence boundary detection around embeds [#2715](https://github.com/singerdmx/flutter-quill/issues/2715)

## [11.5.0] - 2025-10-18

Expand Down
38 changes: 38 additions & 0 deletions lib/src/controller/quill_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '../document/nodes/leaf.dart';
import '../document/structs/doc_change.dart';
import '../document/style.dart';
import '../editor/config/editor_config.dart';
import '../editor/raw_editor/offset_mapping.dart';
import '../editor/raw_editor/raw_editor_state.dart';
import '../editor_toolbar_controller_shared/clipboard/clipboard_service_provider.dart';
import 'clipboard/quill_controller_paste.dart';
Expand Down Expand Up @@ -81,6 +82,7 @@ class QuillController extends ChangeNotifier {
set document(Document doc) {
_document = doc;
_setDocumentSearchProperties();
_cachedOffsetMapping = null;

// Prevent the selection from
_selection = const TextSelection(baseOffset: 0, extentOffset: 0);
Expand Down Expand Up @@ -137,6 +139,36 @@ class QuillController extends ChangeNotifier {
selection: selection,
);

/// Cached offset mapping between document offsets and expanded-text offsets.
OffsetMapping? _cachedOffsetMapping;

/// Returns the [OffsetMapping] for the current document state.
///
/// The mapping is lazily built and cached; it is invalidated automatically
/// whenever the controller notifies listeners (i.e. on every document or
/// selection change).
OffsetMapping get offsetMapping {
return _cachedOffsetMapping ??= buildOffsetMapping(
document.toDelta(),
_editorConfig?.embedBuilders,
_editorConfig?.unknownEmbedBuilder,
);
}

/// A [TextEditingValue] with embeds expanded to their [EmbedBuilder.toPlainText]
/// representation and the selection mapped to expanded-text offsets.
///
/// This is sent to the platform so the OS keyboard can detect word and
/// sentence boundaries around inline embeds (e.g. for
/// [TextCapitalization.sentences]).
TextEditingValue get expandedTextEditingValue {
final mapping = offsetMapping;
return TextEditingValue(
text: mapping.expandedText,
selection: mapping.docToExpandedSelection(selection),
);
}

/// Only attributes applied to all characters within this range are
/// included in the result.
Style getSelectionStyle() {
Expand Down Expand Up @@ -433,6 +465,12 @@ class QuillController extends ChangeNotifier {
notifyListeners();
}

@override
void notifyListeners() {
_cachedOffsetMapping = null;
super.notifyListeners();
}

@override
void addListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `addListener` won't be called on a
Expand Down
9 changes: 9 additions & 0 deletions lib/src/editor/embed/embed_editor_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ abstract class EmbedBuilder {
String get key;
bool get expanded => true;

/// The character length this embed occupies in the text sent to the platform.
///
/// Defaults to `1` (the single object-replacement character `\uFFFC`).
/// Override this together with [toPlainText] to return a value matching
/// the length of the plain-text representation so that the OS keyboard
/// can correctly detect word and sentence boundaries around the embed
/// (e.g. for [TextCapitalization.sentences]).
int get length => 1;

WidgetSpan buildWidgetSpan(Widget widget) {
return WidgetSpan(child: widget);
}
Expand Down
237 changes: 237 additions & 0 deletions lib/src/editor/raw_editor/offset_mapping.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import 'package:flutter/services.dart' show TextSelection, TextRange;

import '../../../quill_delta.dart';
import '../../document/nodes/embeddable.dart';
import '../../document/nodes/leaf.dart';
import '../embed/embed_editor_builder.dart';

/// Records the position and expanded length of a single embed
/// whose `toPlainText()` returns a string longer than 1 character.
class EmbedSpan {
const EmbedSpan({
required this.docOffset,
required this.expandedOffset,
required this.expandedLength,
});

/// Offset of this embed in the document model (where it is length 1).
final int docOffset;

/// Offset of this embed in the expanded text.
final int expandedOffset;

/// Length of the embed's text in expanded form.
final int expandedLength;
}

/// Bidirectional mapping between document offsets (where embeds are length 1)
/// and expanded-text offsets (where embeds use their `toPlainText()` length).
///
/// Used exclusively at the platform text-input boundary so that the OS
/// keyboard sees real text for sentence/word boundary detection.
class OffsetMapping {
OffsetMapping._({
required this.expandedText,
required List<EmbedSpan> embeds,
}) : _embeds = embeds;

/// The plain text with embeds expanded to their `toPlainText()` value.
final String expandedText;

/// Embed spans sorted by [EmbedSpan.docOffset].
final List<EmbedSpan> _embeds;

/// Convert a document offset to the corresponding expanded-text offset.
int docToExpanded(int docOffset) {
var shift = 0;
for (final embed in _embeds) {
if (embed.docOffset < docOffset) {
shift += embed.expandedLength - 1;
} else {
break;
}
}
return docOffset + shift;
}

/// Convert an expanded-text offset to a document offset.
///
/// If the offset falls inside an embed's expanded text, snaps to the
/// nearest boundary (start or end of the embed in document space).
int expandedToDoc(int expandedOffset) {
var shift = 0;
for (final embed in _embeds) {
final embedStart = embed.expandedOffset;
final embedEnd = embed.expandedOffset + embed.expandedLength;

if (expandedOffset <= embedStart) {
break;
} else if (expandedOffset < embedEnd) {
// Inside the embed — snap to nearest boundary.
final distToStart = expandedOffset - embedStart;
final distToEnd = embedEnd - expandedOffset;
if (distToStart <= distToEnd) {
return embed.docOffset; // before embed
} else {
return embed.docOffset + 1; // after embed
}
} else {
shift += embed.expandedLength - 1;
}
}
return expandedOffset - shift;
}

/// Convert an expanded-text offset to a document offset.
///
/// If the offset falls inside an embed's expanded text, snaps to the
/// **start** of the embed in document space.
/// Use this for the start of a deletion range.
int expandedToDocFloor(int expandedOffset) {
var shift = 0;
for (final embed in _embeds) {
final embedStart = embed.expandedOffset;
final embedEnd = embed.expandedOffset + embed.expandedLength;

if (expandedOffset <= embedStart) {
break;
} else if (expandedOffset < embedEnd) {
return embed.docOffset;
} else {
shift += embed.expandedLength - 1;
}
}
return expandedOffset - shift;
}

/// Convert an expanded-text offset to a document offset.
///
/// If the offset falls inside an embed's expanded text, snaps to the
/// **end** (one past) the embed in document space.
/// Use this for the end of a deletion range.
int expandedToDocCeil(int expandedOffset) {
var shift = 0;
for (final embed in _embeds) {
final embedStart = embed.expandedOffset;
final embedEnd = embed.expandedOffset + embed.expandedLength;

if (expandedOffset <= embedStart) {
break;
} else if (expandedOffset <= embedEnd) {
// At or inside the embed — snap to after the embed.
return embed.docOffset + 1;
} else {
shift += embed.expandedLength - 1;
}
}
return expandedOffset - shift;
}

/// Convert a document-space [TextSelection] to expanded-text space.
TextSelection docToExpandedSelection(TextSelection selection) {
return selection.copyWith(
baseOffset: docToExpanded(selection.baseOffset),
extentOffset: docToExpanded(selection.extentOffset),
);
}

/// Convert an expanded-text-space [TextSelection] to document space.
TextSelection expandedToDocSelection(TextSelection selection) {
return selection.copyWith(
baseOffset: expandedToDoc(selection.baseOffset),
extentOffset: expandedToDoc(selection.extentOffset),
);
}

/// Convert an expanded-text-space [TextRange] to document space.
TextRange expandedToDocRange(TextRange range) {
return TextRange(
start: expandedToDoc(range.start),
end: expandedToDoc(range.end),
);
}
}

/// Build an [OffsetMapping] by walking the document [delta].
///
/// For each embed operation, looks up the matching [EmbedBuilder] from
/// [embedBuilders] (or [unknownEmbedBuilder]) and calls `toPlainText()`.
/// If the expanded text differs from the single-character default, an
/// [EmbedSpan] is recorded.
OffsetMapping buildOffsetMapping(
Delta delta,
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
) {
final buffer = StringBuffer();
final embeds = <EmbedSpan>[];
var docOffset = 0;
var expandedOffset = 0;

for (final op in delta.toList()) {
if (!op.isInsert) continue;

if (op.data is Map) {
// Embed operation.
final embeddable =
Embeddable.fromJson(Map<String, dynamic>.from(op.data as Map));
final embedText = _getEmbedPlainText(
embeddable,
embedBuilders,
unknownEmbedBuilder,
);

buffer.write(embedText);

if (embedText.length != 1 ||
embedText != Embed.kObjectReplacementCharacter) {
embeds.add(EmbedSpan(
docOffset: docOffset,
expandedOffset: expandedOffset,
expandedLength: embedText.length,
));
}

docOffset += 1; // always 1 in document/delta
expandedOffset += embedText.length;
} else {
// Text operation.
final text = op.data as String;
buffer.write(text);
docOffset += text.length;
expandedOffset += text.length;
}
}

return OffsetMapping._(
expandedText: buffer.toString(),
embeds: embeds,
);
}

/// Look up the matching [EmbedBuilder] for [embeddable] and call
/// `toPlainText()`. Falls back to [unknownEmbedBuilder] or `\uFFFC`.
String _getEmbedPlainText(
Embeddable embeddable,
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
) {
final embedNode = Embed(embeddable);

if (embedBuilders != null) {
for (final builder in embedBuilders) {
if (builder.key == embeddable.type) {
final text = builder.toPlainText(embedNode);
// Enforce non-empty — fall back to replacement character.
return text.isEmpty ? Embed.kObjectReplacementCharacter : text;
}
}
}

if (unknownEmbedBuilder != null) {
final text = unknownEmbedBuilder.toPlainText(embedNode);
return text.isEmpty ? Embed.kObjectReplacementCharacter : text;
}

return Embed.kObjectReplacementCharacter;
}
Loading