Skip to content

Commit 1676e43

Browse files
[Quill] - Serialize MutableDocument to Quill Deltas document (Resolves #2117) (#2119)
1 parent a701ff5 commit 1676e43

File tree

8 files changed

+996
-144
lines changed

8 files changed

+996
-144
lines changed
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:dart_quill_delta/dart_quill_delta.dart';
3+
import 'package:flutter/foundation.dart';
4+
import 'package:super_editor/super_editor.dart';
5+
import 'package:super_editor_quill/src/content/formatting.dart';
6+
import 'package:super_editor_quill/src/content/multimedia.dart';
7+
8+
/// A [DeltaSerializer] that serializes [ParagraphNode]s into deltas.
9+
const paragraphDeltaSerializer = ParagraphDeltaSerializer();
10+
11+
class ParagraphDeltaSerializer extends TextBlockDeltaSerializer {
12+
const ParagraphDeltaSerializer();
13+
14+
@override
15+
bool shouldSerialize(DocumentNode node) => node is ParagraphNode;
16+
17+
@override
18+
Map<String, dynamic> getBlockFormats(TextNode textBlock) {
19+
if (textBlock is! ParagraphNode) {
20+
// This shouldn't happen, but we do a sane thing if it does.
21+
return super.getBlockFormats(textBlock);
22+
}
23+
24+
final formats = super.getBlockFormats(textBlock);
25+
if (textBlock.indent != 0) {
26+
formats["indent"] = textBlock.indent;
27+
}
28+
return formats;
29+
}
30+
}
31+
32+
/// A [DataSerializer] that serializes [ListItemNode]s into deltas.
33+
const listItemDeltaSerializer = ListItemDeltaSerializer();
34+
35+
class ListItemDeltaSerializer extends TextBlockDeltaSerializer {
36+
const ListItemDeltaSerializer();
37+
38+
@override
39+
bool shouldSerialize(DocumentNode node) => node is ListItemNode;
40+
41+
@override
42+
Map<String, dynamic> getBlockFormats(TextNode textBlock) {
43+
if (textBlock is! ListItemNode) {
44+
// This shouldn't happen, but we do a sane thing if it does.
45+
return super.getBlockFormats(textBlock);
46+
}
47+
48+
final formats = super.getBlockFormats(textBlock);
49+
switch (textBlock.type) {
50+
case ListItemType.ordered:
51+
formats["list"] = "ordered";
52+
case ListItemType.unordered:
53+
formats["list"] = "bullet";
54+
}
55+
return formats;
56+
}
57+
}
58+
59+
/// A [DeltaSerializer] that serializes [TaskNode]s into deltas.
60+
const taskDeltaSerializer = TaskDeltaSerializer();
61+
62+
class TaskDeltaSerializer extends TextBlockDeltaSerializer {
63+
const TaskDeltaSerializer();
64+
65+
@override
66+
bool shouldSerialize(DocumentNode node) => node is TaskNode;
67+
68+
@override
69+
Map<String, dynamic> getBlockFormats(TextNode textBlock) {
70+
if (textBlock is! TaskNode) {
71+
// This shouldn't happen, but we do a sane thing if it does.
72+
return super.getBlockFormats(textBlock);
73+
}
74+
75+
final formats = super.getBlockFormats(textBlock);
76+
formats["list"] = textBlock.isComplete ? "checked" : "unchecked";
77+
return formats;
78+
}
79+
}
80+
81+
/// A [DeltaSerializer] that serializes [ImageNode]s into deltas.
82+
const imageDeltaSerializer = FunctionalDeltaSerializer(_serializeImage);
83+
bool _serializeImage(DocumentNode node, Delta deltas) {
84+
if (node is! ImageNode) {
85+
return false;
86+
}
87+
88+
deltas.operations.add(
89+
Operation.insert({
90+
"image": node.imageUrl,
91+
}),
92+
);
93+
94+
return true;
95+
}
96+
97+
/// A [DeltaSerializer] that serializes [VideoNode]s into deltas.
98+
const videoDeltaSerializer = FunctionalDeltaSerializer(_serializeVideo);
99+
bool _serializeVideo(DocumentNode node, Delta deltas) {
100+
if (node is! VideoNode) {
101+
return false;
102+
}
103+
104+
deltas.operations.add(
105+
Operation.insert({
106+
"video": node.url,
107+
}),
108+
);
109+
110+
return true;
111+
}
112+
113+
/// A [DeltaSerializer] that serializes [AudioNode]s to deltas.
114+
const audioDeltaSerializer = FunctionalDeltaSerializer(_serializeAudio);
115+
bool _serializeAudio(DocumentNode node, Delta deltas) {
116+
if (node is! AudioNode) {
117+
return false;
118+
}
119+
120+
deltas.operations.add(
121+
Operation.insert({
122+
"audio": node.url,
123+
}),
124+
);
125+
126+
return true;
127+
}
128+
129+
/// A [DeltaSerializer] that serializes [FileNode]s into deltas.
130+
const fileDeltaSerializer = FunctionalDeltaSerializer(_serializeFile);
131+
bool _serializeFile(DocumentNode node, Delta deltas) {
132+
if (node is! FileNode) {
133+
return false;
134+
}
135+
136+
deltas.operations.add(
137+
Operation.insert({
138+
"file": node.url,
139+
}),
140+
);
141+
142+
return true;
143+
}
144+
145+
/// A [DeltaSerializer] that includes standard Quill Delta rules for
146+
/// serializing text blocks, e.g., paragraphs, lists, and tasks.
147+
class TextBlockDeltaSerializer implements DeltaSerializer {
148+
const TextBlockDeltaSerializer();
149+
150+
@override
151+
bool serialize(DocumentNode node, Delta deltas) {
152+
if (!shouldSerialize(node)) {
153+
return false;
154+
}
155+
final textBlock = node as TextNode;
156+
157+
final blockFormats = getBlockFormats(textBlock);
158+
159+
var spans = textBlock.text.computeAttributionSpans().toList();
160+
if (spans.isEmpty) {
161+
// The text is empty. Inject a span so that our standard delta generation
162+
// behavior below still works.
163+
spans = [const MultiAttributionSpan(attributions: {}, start: 0, end: 0)];
164+
}
165+
166+
for (int i = 0; i < spans.length; i += 1) {
167+
final span = spans[i];
168+
final text = textBlock.text.text.substring(span.start, textBlock.text.text.isNotEmpty ? span.end + 1 : span.end);
169+
final inlineAttributes = getInlineAttributesFor(span.attributions);
170+
171+
final previousDelta = deltas.operations.lastOrNull;
172+
final newDelta = Operation.insert(
173+
text,
174+
inlineAttributes.isNotEmpty ? inlineAttributes : null,
175+
);
176+
if (previousDelta != null && newDelta.canMergeWith(previousDelta)) {
177+
deltas.operations[deltas.operations.length - 1] = newDelta.mergeWith(previousDelta);
178+
continue;
179+
}
180+
181+
deltas.operations.add(newDelta);
182+
}
183+
184+
if (textBlock.text.text.endsWith("\n")) {
185+
// There's already a trailing newline. No need to add another one.
186+
return true;
187+
}
188+
189+
final newlineDelta = Operation.insert("\n", blockFormats);
190+
final previousDelta = deltas.operations[deltas.operations.length - 1];
191+
if (newlineDelta.canMergeWith(previousDelta)) {
192+
deltas.operations[deltas.operations.length - 1] = newlineDelta.mergeWith(previousDelta);
193+
} else {
194+
deltas.operations.add(newlineDelta);
195+
}
196+
197+
return true;
198+
}
199+
200+
@protected
201+
bool shouldSerialize(DocumentNode node) {
202+
return node is TextNode;
203+
}
204+
205+
/// Given the [textBlock], decides what combination of block-level attributes
206+
/// should be applied to the Quill Delta for this text block.
207+
@protected
208+
Map<String, dynamic> getBlockFormats(TextNode textBlock) {
209+
final blockAttributes = <String, dynamic>{};
210+
211+
// Add all the block-level formats that aren't mutually exclusive.
212+
if (textBlock.metadata["textAlign"] != null) {
213+
blockAttributes["align"] = textBlock.metadata["textAlign"];
214+
}
215+
216+
final blockType = textBlock.metadata["blockType"] as Attribution?;
217+
if (blockType == null) {
218+
return blockAttributes;
219+
}
220+
221+
// Add the mutually exclusive block format.
222+
switch (blockType) {
223+
case header1Attribution:
224+
blockAttributes["header"] = 1;
225+
case header2Attribution:
226+
blockAttributes["header"] = 2;
227+
case header3Attribution:
228+
blockAttributes["header"] = 3;
229+
case header4Attribution:
230+
blockAttributes["header"] = 4;
231+
case header5Attribution:
232+
blockAttributes["header"] = 5;
233+
case header6Attribution:
234+
blockAttributes["header"] = 6;
235+
case blockquoteAttribution:
236+
blockAttributes["blockquote"] = true;
237+
case codeAttribution:
238+
blockAttributes["code-block"] = "plain";
239+
}
240+
241+
return blockAttributes;
242+
}
243+
244+
/// Given a set of [superEditorAttributions], serializes those into Quill Delta
245+
/// inline text attributes, returning all attributes in a map that should be set as
246+
/// the "attributes" in an insertion delta.
247+
@protected
248+
Map<String, dynamic> getInlineAttributesFor(Set<Attribution> superEditorAttributions) {
249+
final attributes = <String, dynamic>{};
250+
251+
for (final attribution in superEditorAttributions) {
252+
if (attribution == boldAttribution) {
253+
attributes["bold"] = true;
254+
continue;
255+
}
256+
if (attribution == italicsAttribution) {
257+
attributes["italic"] = true;
258+
continue;
259+
}
260+
if (attribution == strikethroughAttribution) {
261+
attributes["strike"] = true;
262+
continue;
263+
}
264+
if (attribution == underlineAttribution) {
265+
attributes["underline"] = true;
266+
continue;
267+
}
268+
if (attribution == superscriptAttribution) {
269+
attributes["script"] = "super";
270+
continue;
271+
}
272+
if (attribution == subscriptAttribution) {
273+
attributes["script"] = "sub";
274+
continue;
275+
}
276+
if (attribution is ColorAttribution) {
277+
attributes["color"] = "#${attribution.color.value.toRadixString(16).substring(2)}";
278+
continue;
279+
}
280+
if (attribution is BackgroundColorAttribution) {
281+
attributes["background"] = "#${attribution.color.value.toRadixString(16).substring(2)}";
282+
continue;
283+
}
284+
if (attribution is FontFamilyAttribution) {
285+
attributes["font"] = attribution.fontFamily;
286+
continue;
287+
}
288+
if (attribution is NamedFontSizeAttribution) {
289+
attributes["size"] = attribution.fontSizeName;
290+
continue;
291+
}
292+
if (attribution is FontSizeAttribution) {
293+
attributes["size"] = attribution.fontSize;
294+
continue;
295+
}
296+
if (attribution is LinkAttribution) {
297+
attributes["link"] = attribution.url;
298+
continue;
299+
}
300+
}
301+
302+
return attributes;
303+
}
304+
}
305+
306+
/// A [DeltaSerializer] that forwards to a given delegate function.
307+
class FunctionalDeltaSerializer implements DeltaSerializer {
308+
const FunctionalDeltaSerializer(this._delegate);
309+
310+
final DeltaSerializerDelegate _delegate;
311+
312+
@override
313+
bool serialize(DocumentNode node, Delta deltas) => _delegate(node, deltas);
314+
}
315+
316+
typedef DeltaSerializerDelegate = bool Function(DocumentNode node, Delta deltas);
317+
318+
/// Serializes some part of a [MutableDocument] to a Quill Delta document.
319+
///
320+
/// For example, a [DeltaSerializer] might serialize a [ParagraphNode], or
321+
/// an [ImageNode].
322+
abstract interface class DeltaSerializer {
323+
/// Tries to serialize the given [DocumentNode] into the given [deltas],
324+
/// returning `true` if this serializer was able to serialize the [node],
325+
/// or `false` if this serializer wasn't made to serialize this kind of [node].
326+
///
327+
/// For example, serializing a [ParagraphNode], or an [ImageNode], into
328+
/// an insertion operation.
329+
bool serialize(DocumentNode node, Delta deltas);
330+
}
331+
332+
extension DeltaSerialization on Operation {
333+
bool canMergeWith(Operation previousDelta) {
334+
if (!isInsert) {
335+
// We've only implement this for insertions, for now.
336+
// TODO: Add support for retain/delete.
337+
return false;
338+
}
339+
340+
if (value is! String || previousDelta.value is! String) {
341+
// One or both of the deltas aren't text. Only text can be merged.
342+
return false;
343+
}
344+
345+
// If the attributes are equivalent then we can merge the text deltas.
346+
if (const DeepCollectionEquality().equals(previousDelta.attributes, attributes)) {
347+
return true;
348+
}
349+
if (previousDelta.attributes == null && attributes!.isEmpty) {
350+
return true;
351+
}
352+
if (attributes == null && previousDelta.attributes!.isEmpty) {
353+
return true;
354+
}
355+
356+
// There's a difference in the attributes. We need separate deltas.
357+
return false;
358+
}
359+
360+
Operation mergeWith(Operation previousDelta) {
361+
if (!canMergeWith(previousDelta)) {
362+
throw Exception(
363+
"Tried to merge two deltas that can't be merged. Previous delta: $previousDelta. Next delta: $this");
364+
}
365+
366+
return Operation.insert(
367+
"${previousDelta.value as String}${value as String}",
368+
previousDelta.attributes,
369+
);
370+
}
371+
}
372+
373+
extension NewlineCharacter on String {
374+
String toNewlineString() => toString().replaceAll("\n", "⏎");
375+
}

0 commit comments

Comments
 (0)