Skip to content

Commit 1b4c7a2

Browse files
Cherry Pick: [Quill] - Make consecutive node merges configurable (Resolves #2510) (#2512)
1 parent 9f253a7 commit 1b4c7a2

File tree

5 files changed

+310
-85
lines changed

5 files changed

+310
-85
lines changed

super_editor/clones/quill/lib/app.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,18 @@ This is regular text right below header 2.
106106
Some **bold** text.
107107
108108
> This is a blockquote.
109+
> It can span multiple lines.
109110
110-
* This is a list item.
111+
* This is a bulleted list item.
112+
113+
1. This is a numerical list item.
111114
112115
```
113116
This is a code block.
117+
It can span multiple lines.
114118
```
119+
120+
The end.
115121
''');
116122
}
117123

super_editor_quill/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ Quill Delta document. Also, a `SuperEditor` document can be serialized to a Quil
3434

3535
Regardless of the incoming or outgoing document format, the actual editing pipeline within `SuperEditor`
3636
remains the same. Thus, you could start a document from Markdown, and then export a document to
37-
Quill Delta, or vis-a-versa. `SuperEditor` internals are format agnostic.
37+
Quill Delta, or vis-a-versa. `SuperEditor` internals are format agnostic.
38+
39+
## References
40+
* Interactive Playground: https://v1.quilljs.com/playground/

super_editor_quill/lib/src/parsing/parser.dart

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,75 @@ import 'package:super_editor_quill/src/parsing/inline_formats.dart';
2222
/// and a [MutableComposer]. The document must be empty.
2323
/// {@endtemplate}
2424
///
25+
/// {@template merge_consecutive_blocks}
26+
/// ### Merging consecutive blocks
27+
///
28+
/// The Delta format creates some ambiguity around when multiple lines should
29+
/// be combined into a single block vs one block per line. E.g., a code block
30+
/// with multiple lines of code vs a series of independent code blocks.
31+
///
32+
/// [blockMergeRules] explicitly tells the parser which consecutive
33+
/// [DocumentNode]s should be merged together when not separated by an unstyled
34+
/// newline in the given deltas.
35+
///
36+
/// Example of consecutive code blocks that would be merged (if requested):
37+
///
38+
/// [
39+
/// { "insert": "Code line one" },
40+
/// { "insert": "\n", "attributed": { "code-block": "plain"} },
41+
/// { "insert": "Code line two" },
42+
/// { "insert": "\n", "attributed": { "code-block": "plain"} },
43+
/// ]
44+
///
45+
/// Example of code blocks, separated by an unstyled newline, that wouldn't be merged:
46+
///
47+
/// [
48+
/// { "insert": "Code line one" },
49+
/// { "insert": "\n", "attributed": { "code-block": "plain"} },
50+
/// { "insert": "\n" },
51+
/// { "insert": "Code line two" },
52+
/// { "insert": "\n", "attributed": { "code-block": "plain"} },
53+
/// ]
54+
///
55+
/// {@endtemplate}
56+
///
2557
/// For more information about the Quill Delta format, see the official
2658
/// documentation: https://quilljs.com/docs/delta/
2759
MutableDocument parseQuillDeltaDocument(
2860
Map<String, dynamic> deltaDocument, {
2961
Editor? customEditor,
3062
List<BlockDeltaFormat> blockFormats = defaultBlockFormats,
63+
List<DeltaBlockMergeRule> blockMergeRules = defaultBlockMergeRules,
3164
List<InlineDeltaFormat> inlineFormats = defaultInlineFormats,
3265
List<InlineEmbedFormat> inlineEmbedFormats = const [],
3366
List<BlockDeltaFormat> embedBlockFormats = defaultEmbedBockFormats,
3467
}) {
3568
return parseQuillDeltaOps(
3669
deltaDocument["ops"],
3770
customEditor: customEditor,
71+
blockMergeRules: blockMergeRules,
3872
blockFormats: blockFormats,
3973
inlineFormats: inlineFormats,
4074
inlineEmbedFormats: inlineEmbedFormats,
4175
embedBlockFormats: embedBlockFormats,
4276
);
4377
}
4478

45-
/// Parses a list Quill Delta operations (as JSON) into a [MutableDocument].
79+
/// Parses a list of Quill Delta operations (as JSON) into a [MutableDocument].
4680
///
4781
/// This parser is the same as [parseQuillDeltaDocument] except that this method
4882
/// directly accepts the operations list instead of the whole document map. This
4983
/// method is provided for convenience because in some situations only the
5084
/// operations are exchanged, rather than the whole document object.
5185
///
5286
/// {@macro parse_deltas_custom_editor}
87+
///
88+
/// {@macro merge_consecutive_blocks}
5389
MutableDocument parseQuillDeltaOps(
5490
List<dynamic> deltaOps, {
5591
Editor? customEditor,
5692
List<BlockDeltaFormat> blockFormats = defaultBlockFormats,
93+
List<DeltaBlockMergeRule> blockMergeRules = defaultBlockMergeRules,
5794
List<InlineDeltaFormat> inlineFormats = defaultInlineFormats,
5895
List<InlineEmbedFormat> inlineEmbedFormats = const [],
5996
List<BlockDeltaFormat> embedBlockFormats = defaultEmbedBockFormats,
@@ -117,6 +154,7 @@ MutableDocument parseQuillDeltaOps(
117154
delta.applyToDocument(
118155
editor,
119156
blockFormats: blockFormats,
157+
blockMergeRules: blockMergeRules,
120158
inlineFormats: inlineFormats,
121159
inlineEmbedFormats: inlineEmbedFormats,
122160
embedBlockFormats: embedBlockFormats,
@@ -179,6 +217,7 @@ extension OperationParser on Operation {
179217
void applyToDocument(
180218
Editor editor, {
181219
required List<BlockDeltaFormat> blockFormats,
220+
List<DeltaBlockMergeRule> blockMergeRules = defaultBlockMergeRules,
182221
required List<InlineDeltaFormat> inlineFormats,
183222
required List<InlineEmbedFormat> inlineEmbedFormats,
184223
required List<BlockDeltaFormat> embedBlockFormats,
@@ -197,44 +236,65 @@ extension OperationParser on Operation {
197236
_doInsertMedia(editor, composer, inlineEmbedFormats, embedBlockFormats);
198237
}
199238

200-
// Deduplicate all back-to-back code blocks.
239+
// Merge consecutive blocks as desired by the given node types.
201240
final document = editor.context.find<MutableDocument>(Editor.documentKey);
202241
if (document.nodeCount < 3) {
203-
// Minimum of 3 nodes: code, code, newline.
242+
// Minimum of 3 nodes: block, block, newline.
204243
break;
205244
}
206245

207-
var codeBlocks = <ParagraphNode>[];
246+
// Beginning with the last non-empty node, move backwards, collecting all
247+
// nodes that should be merged into one.
248+
final nodeBeforeTrailingNewline = document.getNodeBefore(document.last)!;
249+
final blockTypeToMerge = nodeBeforeTrailingNewline.getMetadataValue(NodeMetadata.blockType);
250+
var blocksToMerge = <ParagraphNode>[];
208251
for (int i = document.nodeCount - 2; i >= 0; i -= 1) {
209252
final node = document.getNodeAt(i)!;
210253
if (node is! ParagraphNode) {
211254
break;
212255
}
213-
if (node.getMetadataValue("blockType") != codeAttribution) {
256+
257+
var shouldMerge = false;
258+
for (final rule in blockMergeRules) {
259+
final ruleShouldMerge = rule.shouldMerge(blockTypeToMerge, node.getMetadataValue(NodeMetadata.blockType));
260+
if (ruleShouldMerge == true) {
261+
// The rule says we definitely want to merge.
262+
shouldMerge = true;
263+
break;
264+
}
265+
if (ruleShouldMerge == false) {
266+
// The rule says we definitely don't want to merge.
267+
shouldMerge = false;
268+
break;
269+
}
270+
}
271+
if (!shouldMerge) {
272+
// Our merge rules don't want us to merge this node.
214273
break;
215274
}
216275

217-
codeBlocks.add(node);
276+
blocksToMerge.add(node);
218277
}
219278

220-
if (codeBlocks.length < 2) {
279+
if (blocksToMerge.length < 2) {
221280
break;
222281
}
223282

224-
codeBlocks = codeBlocks.reversed.toList();
225-
final mergeNode = codeBlocks.first;
226-
var codeToMove = codeBlocks[1].text.insertString(textToInsert: "\n", startOffset: 0);
227-
for (int i = 2; i < codeBlocks.length; i += 1) {
228-
codeToMove = codeToMove.copyAndAppend(codeBlocks[i].text.insertString(textToInsert: "\n", startOffset: 0));
283+
blocksToMerge = blocksToMerge.reversed.toList();
284+
final mergeNode = blocksToMerge.first;
285+
var nodeContentToMove = blocksToMerge[1].text.insertString(textToInsert: "\n", startOffset: 0);
286+
for (int i = 2; i < blocksToMerge.length; i += 1) {
287+
nodeContentToMove =
288+
nodeContentToMove.copyAndAppend(blocksToMerge[i].text.insertString(textToInsert: "\n", startOffset: 0));
229289
}
230290

231291
editor.execute([
232292
InsertAttributedTextRequest(
233293
DocumentPosition(nodeId: mergeNode.id, nodePosition: mergeNode.endPosition),
234-
codeToMove,
294+
nodeContentToMove,
235295
),
236-
for (int i = 1; i < codeBlocks.length; i += 1) //
237-
DeleteNodeRequest(nodeId: codeBlocks[i].id),
296+
for (int i = 1; i < blocksToMerge.length; i += 1) //
297+
DeleteNodeRequest(nodeId: blocksToMerge[i].id),
238298
]);
239299

240300
case DeltaOperationType.retain:
@@ -457,7 +517,7 @@ extension OperationParser on Operation {
457517

458518
// The caret wants to move beyond this paragraph.
459519
unitsToMove -= selectedNode.text.length - currentPosition.offset;
460-
selectedNode = document.getNodeAfter(selectedNode)!;
520+
selectedNode = document.getNodeAfterById(selectedNode.id)!;
461521
caretPosition = DocumentPosition(
462522
nodeId: selectedNode.id,
463523
nodePosition: selectedNode.beginningPosition,
@@ -476,7 +536,7 @@ extension OperationParser on Operation {
476536

477537
// The deltas want to retain more beyond this node.
478538
unitsToMove -= 1;
479-
selectedNode = document.getNodeAfter(selectedNode)!;
539+
selectedNode = document.getNodeAfterById(selectedNode.id)!;
480540
caretPosition = DocumentPosition(
481541
nodeId: selectedNode.id,
482542
nodePosition: selectedNode.beginningPosition,
@@ -510,3 +570,39 @@ enum DeltaOperationType {
510570
retain,
511571
delete,
512572
}
573+
574+
/// The standard set of [DeltaBlockMergeRule]s used when parsing Quill Deltas.
575+
const defaultBlockMergeRules = [
576+
MergeBlock(blockquoteAttribution),
577+
MergeBlock(codeAttribution),
578+
];
579+
580+
/// A rule that decides whether a given [DocumentNode] should be merged into
581+
/// the node before it, when creating a [Document] from Quill Deltas.
582+
///
583+
/// This is useful, for example, to place multiple lines of code within a
584+
/// single code block.
585+
abstract interface class DeltaBlockMergeRule {
586+
/// Returns `true` if two consecutive blocks with the given types should merge,
587+
/// `false` if they shouldn't, or `null` if this rule has no opinion about the merge.
588+
bool? shouldMerge(Attribution block1, Attribution block2);
589+
}
590+
591+
/// A [DeltaBlockMergeRule] that chooses to merge blocks whose type `==`
592+
/// the given block type.
593+
class MergeBlock implements DeltaBlockMergeRule {
594+
const MergeBlock(this._blockType);
595+
596+
final Attribution _blockType;
597+
598+
@override
599+
bool? shouldMerge(Attribution block1, Attribution block2) {
600+
if (block1 == _blockType && block2 == _blockType) {
601+
// Yes, try to merge them.
602+
return true;
603+
}
604+
605+
// This isn't our block type. We don't have an opinion.
606+
return null;
607+
}
608+
}

0 commit comments

Comments
 (0)