Skip to content

Commit 2fb0e8d

Browse files
committed
feat: #1649 [FR] Convert quill delta to appflowy document
1 parent 1a2af1c commit 2fb0e8d

File tree

5 files changed

+802
-1
lines changed

5 files changed

+802
-1
lines changed

frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ enum ExportFileType {
1414
json,
1515
markdown,
1616
html,
17+
delta,
1718
}
1819

1920
extension on ExportFileType {
2021
String get extension {
2122
switch (this) {
2223
case ExportFileType.json:
24+
case ExportFileType.delta:
2325
return 'json';
2426
case ExportFileType.markdown:
2527
return 'md';
@@ -117,6 +119,9 @@ class _HomePageState extends State<HomePage> {
117119
_buildListTile(context, 'Import From Markdown', () {
118120
_importFile(ExportFileType.markdown);
119121
}),
122+
_buildListTile(context, 'Import From Quill Delta', () {
123+
_importFile(ExportFileType.delta);
124+
}),
120125

121126
// Theme Demo
122127
_buildSeparator(context, 'Theme Demo'),
@@ -224,6 +229,7 @@ class _HomePageState extends State<HomePage> {
224229
result = documentToMarkdown(editorState.document);
225230
break;
226231
case ExportFileType.html:
232+
case ExportFileType.delta:
227233
throw UnimplementedError();
228234
}
229235

@@ -280,6 +286,17 @@ class _HomePageState extends State<HomePage> {
280286
case ExportFileType.markdown:
281287
jsonString = jsonEncode(markdownToDocument(plainText).toJson());
282288
break;
289+
case ExportFileType.delta:
290+
jsonString = jsonEncode(
291+
DeltaDocumentConvert()
292+
.convertFromJSON(
293+
jsonDecode(
294+
plainText.replaceAll('\\\\\n', '\\n'),
295+
),
296+
)
297+
.toJson(),
298+
);
299+
break;
283300
case ExportFileType.html:
284301
throw UnimplementedError();
285302
}

frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ EXTERNAL SOURCES:
3535

3636
SPEC CHECKSUMS:
3737
flowy_infra_ui: c34d49d615ed9fe552cd47f90d7850815a74e9e9
38-
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
38+
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
3939
path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
4040
rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
4141
shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727

frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,4 @@ export 'src/plugins/markdown/encoder/parser/text_node_parser.dart';
4141
export 'src/plugins/markdown/encoder/parser/image_node_parser.dart';
4242
export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart';
4343
export 'src/plugins/markdown/document_markdown.dart';
44+
export 'src/plugins/quill_delta/delta_document_encoder.dart';
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import 'package:appflowy_editor/src/core/document/attributes.dart';
2+
import 'package:appflowy_editor/src/core/document/document.dart';
3+
import 'package:appflowy_editor/src/core/document/node.dart';
4+
import 'package:appflowy_editor/src/core/document/text_delta.dart';
5+
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
6+
import 'package:flutter/material.dart';
7+
8+
class DeltaDocumentConvert {
9+
DeltaDocumentConvert();
10+
11+
var _number = 1;
12+
final Map<int, List<TextNode>> _bulletedList = {};
13+
14+
Document convertFromJSON(List<dynamic> json) {
15+
final delta = Delta.fromJson(json);
16+
return convertFromDelta(delta);
17+
}
18+
19+
Document convertFromDelta(Delta delta) {
20+
final iter = delta.iterator;
21+
22+
final document = Document.empty();
23+
TextNode textNode = TextNode(delta: Delta());
24+
int path = 0;
25+
26+
while (iter.moveNext()) {
27+
final op = iter.current;
28+
if (op is TextInsert) {
29+
if (op.text != '\n') {
30+
// Attributes associated with a newline character describes formatting for that line.
31+
final texts = op.text.split('\n');
32+
if (texts.length > 1) {
33+
textNode.delta.insert(texts[0]);
34+
document.insert([path++], [textNode]);
35+
textNode = TextNode(delta: Delta()..insert(texts[1]));
36+
} else {
37+
_applyStyle(textNode, op.text, op.attributes);
38+
}
39+
} else {
40+
if (!_containNumberListStyle(op.attributes)) {
41+
_number = 1;
42+
}
43+
_applyListStyle(textNode, op.attributes);
44+
_applyHeaderStyle(textNode, op.attributes);
45+
_applyIndent(textNode, op.attributes);
46+
_applyBlockquote(textNode, op.attributes);
47+
// _applyCodeBlock(textNode, op.attributes);
48+
49+
if (_containIndentBulletedListStyle(op.attributes)) {
50+
final level = _indentLevel(op.attributes);
51+
final path = [
52+
..._bulletedList[level - 1]!.last.path,
53+
_bulletedList[level]!.length - 1,
54+
];
55+
document.insert(path, [textNode]);
56+
} else {
57+
document.insert([path++], [textNode]);
58+
}
59+
textNode = TextNode(delta: Delta());
60+
}
61+
} else {
62+
assert(false, 'op must be TextInsert');
63+
}
64+
}
65+
66+
return document;
67+
}
68+
69+
void _applyStyle(TextNode textNode, String text, Map? attributes) {
70+
Attributes attrs = {};
71+
72+
if (_containsStyle(attributes, 'strike')) {
73+
attrs[BuiltInAttributeKey.strikethrough] = true;
74+
}
75+
if (_containsStyle(attributes, 'underline')) {
76+
attrs[BuiltInAttributeKey.underline] = true;
77+
}
78+
if (_containsStyle(attributes, 'bold')) {
79+
attrs[BuiltInAttributeKey.bold] = true;
80+
}
81+
if (_containsStyle(attributes, 'italic')) {
82+
attrs[BuiltInAttributeKey.italic] = true;
83+
}
84+
final link = attributes?['link'] as String?;
85+
if (link != null) {
86+
attrs[BuiltInAttributeKey.href] = link;
87+
}
88+
final color = attributes?['color'] as String?;
89+
final colorHex = _convertColorToHexString(color);
90+
if (colorHex != null) {
91+
attrs[BuiltInAttributeKey.color] = colorHex;
92+
}
93+
final backgroundColor = attributes?['background'] as String?;
94+
final backgroundHex = _convertColorToHexString(backgroundColor);
95+
if (backgroundHex != null) {
96+
attrs[BuiltInAttributeKey.backgroundColor] = backgroundHex;
97+
}
98+
99+
textNode.delta.insert(text, attributes: attrs);
100+
}
101+
102+
bool _containsStyle(Map? attributes, String key) {
103+
final value = attributes?[key] as bool?;
104+
return value == true;
105+
}
106+
107+
String? _convertColorToHexString(String? color) {
108+
if (color == null) {
109+
return null;
110+
}
111+
if (color.startsWith('#')) {
112+
return '0xFF${color.substring(1)}';
113+
} else if (color.startsWith("rgba")) {
114+
List rgbaList = color.substring(5, color.length - 1).split(',');
115+
return Color.fromRGBO(
116+
int.parse(rgbaList[0]),
117+
int.parse(rgbaList[1]),
118+
int.parse(rgbaList[2]),
119+
double.parse(rgbaList[3]),
120+
).toHex();
121+
}
122+
return null;
123+
}
124+
125+
// convert bullet-list, number-list, check-list to appflowy style list.
126+
void _applyListStyle(TextNode textNode, Map? attributes) {
127+
final indent = attributes?['indent'] as int?;
128+
final list = attributes?['list'] as String?;
129+
if (list != null) {
130+
switch (list) {
131+
case 'bullet':
132+
textNode.updateAttributes({
133+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList,
134+
});
135+
if (indent != null) {
136+
_bulletedList[indent] ??= [];
137+
_bulletedList[indent]?.add(textNode);
138+
} else {
139+
_bulletedList.clear();
140+
_bulletedList[0] ??= [];
141+
_bulletedList[0]?.add(textNode);
142+
}
143+
break;
144+
case 'ordered':
145+
textNode.updateAttributes({
146+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList,
147+
BuiltInAttributeKey.number: _number++,
148+
});
149+
break;
150+
case 'checked':
151+
textNode.updateAttributes({
152+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
153+
BuiltInAttributeKey.checkbox: true,
154+
});
155+
break;
156+
case 'unchecked':
157+
textNode.updateAttributes({
158+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
159+
BuiltInAttributeKey.checkbox: false,
160+
});
161+
break;
162+
}
163+
}
164+
}
165+
166+
bool _containNumberListStyle(Map? attributes) {
167+
final list = attributes?['list'] as String?;
168+
return list == 'ordered';
169+
}
170+
171+
bool _containIndentBulletedListStyle(Map? attributes) {
172+
final list = attributes?['list'] as String?;
173+
final indent = attributes?['indent'] as int?;
174+
return list == 'bullet' && indent != null;
175+
}
176+
177+
int _indentLevel(Map? attributes) {
178+
final indent = attributes?['indent'] as int?;
179+
return indent ?? 1;
180+
}
181+
182+
// convert header to appflowy style heading
183+
void _applyHeaderStyle(TextNode textNode, Map? attributes) {
184+
final header = attributes?['header'] as int?;
185+
if (header != null) {
186+
textNode.updateAttributes({
187+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
188+
BuiltInAttributeKey.heading: 'h$header',
189+
});
190+
}
191+
}
192+
193+
// convert indent to tab
194+
void _applyIndent(TextNode textNode, Map? attributes) {
195+
final indent = attributes?['indent'] as int?;
196+
final list = attributes?['list'] as String?;
197+
if (indent != null && list == null) {
198+
textNode.delta = textNode.delta.compose(
199+
Delta()
200+
..retain(0)
201+
..insert(' ' * indent),
202+
);
203+
}
204+
}
205+
206+
/*
207+
// convert code-block to appflowy style code
208+
void _applyCodeBlock(TextNode textNode, Map? attributes) {
209+
final codeBlock = attributes?['code-block'] as bool?;
210+
if (codeBlock != null) {
211+
textNode.updateAttributes({
212+
BuiltInAttributeKey.subtype: 'code_block',
213+
});
214+
}
215+
}
216+
*/
217+
218+
void _applyBlockquote(TextNode textNode, Map? attributes) {
219+
final blockquote = attributes?['blockquote'] as bool?;
220+
if (blockquote != null) {
221+
textNode.updateAttributes({
222+
BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote,
223+
});
224+
}
225+
}
226+
}
227+
228+
extension on Color {
229+
String toHex() {
230+
return '0x${value.toRadixString(16)}';
231+
}
232+
}

0 commit comments

Comments
 (0)