|
| 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