diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart index 18d74655ed..c4b706c6f6 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/home_page.dart @@ -14,12 +14,14 @@ enum ExportFileType { json, markdown, html, + delta, } extension on ExportFileType { String get extension { switch (this) { case ExportFileType.json: + case ExportFileType.delta: return 'json'; case ExportFileType.markdown: return 'md'; @@ -117,6 +119,9 @@ class _HomePageState extends State { _buildListTile(context, 'Import From Markdown', () { _importFile(ExportFileType.markdown); }), + _buildListTile(context, 'Import From Quill Delta', () { + _importFile(ExportFileType.delta); + }), // Theme Demo _buildSeparator(context, 'Theme Demo'), @@ -224,6 +229,7 @@ class _HomePageState extends State { result = documentToMarkdown(editorState.document); break; case ExportFileType.html: + case ExportFileType.delta: throw UnimplementedError(); } @@ -280,6 +286,17 @@ class _HomePageState extends State { case ExportFileType.markdown: jsonString = jsonEncode(markdownToDocument(plainText).toJson()); break; + case ExportFileType.delta: + jsonString = jsonEncode( + DeltaDocumentConvert() + .convertFromJSON( + jsonDecode( + plainText.replaceAll('\\\\\n', '\\n'), + ), + ) + .toJson(), + ); + break; case ExportFileType.html: throw UnimplementedError(); } diff --git a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock index 1399b50219..971f196463 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock +++ b/frontend/app_flowy/packages/appflowy_editor/example/macos/Podfile.lock @@ -35,7 +35,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: flowy_infra_ui: c34d49d615ed9fe552cd47f90d7850815a74e9e9 - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811 path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727 diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 983badaaa5..cf5c4e2d75 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -41,3 +41,4 @@ export 'src/plugins/markdown/encoder/parser/text_node_parser.dart'; export 'src/plugins/markdown/encoder/parser/image_node_parser.dart'; export 'src/plugins/markdown/decoder/delta_markdown_decoder.dart'; export 'src/plugins/markdown/document_markdown.dart'; +export 'src/plugins/quill_delta/delta_document_encoder.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/quill_delta/delta_document_encoder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/quill_delta/delta_document_encoder.dart new file mode 100644 index 0000000000..e2b71684d7 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/plugins/quill_delta/delta_document_encoder.dart @@ -0,0 +1,232 @@ +import 'package:appflowy_editor/src/core/document/attributes.dart'; +import 'package:appflowy_editor/src/core/document/document.dart'; +import 'package:appflowy_editor/src/core/document/node.dart'; +import 'package:appflowy_editor/src/core/document/text_delta.dart'; +import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart'; +import 'package:flutter/material.dart'; + +class DeltaDocumentConvert { + DeltaDocumentConvert(); + + var _number = 1; + final Map> _bulletedList = {}; + + Document convertFromJSON(List json) { + final delta = Delta.fromJson(json); + return convertFromDelta(delta); + } + + Document convertFromDelta(Delta delta) { + final iter = delta.iterator; + + final document = Document.empty(); + TextNode textNode = TextNode(delta: Delta()); + int path = 0; + + while (iter.moveNext()) { + final op = iter.current; + if (op is TextInsert) { + if (op.text != '\n') { + // Attributes associated with a newline character describes formatting for that line. + final texts = op.text.split('\n'); + if (texts.length > 1) { + textNode.delta.insert(texts[0]); + document.insert([path++], [textNode]); + textNode = TextNode(delta: Delta()..insert(texts[1])); + } else { + _applyStyle(textNode, op.text, op.attributes); + } + } else { + if (!_containNumberListStyle(op.attributes)) { + _number = 1; + } + _applyListStyle(textNode, op.attributes); + _applyHeaderStyle(textNode, op.attributes); + _applyIndent(textNode, op.attributes); + _applyBlockquote(textNode, op.attributes); + // _applyCodeBlock(textNode, op.attributes); + + if (_containIndentBulletedListStyle(op.attributes)) { + final level = _indentLevel(op.attributes); + final path = [ + ..._bulletedList[level - 1]!.last.path, + _bulletedList[level]!.length - 1, + ]; + document.insert(path, [textNode]); + } else { + document.insert([path++], [textNode]); + } + textNode = TextNode(delta: Delta()); + } + } else { + assert(false, 'op must be TextInsert'); + } + } + + return document; + } + + void _applyStyle(TextNode textNode, String text, Map? attributes) { + Attributes attrs = {}; + + if (_containsStyle(attributes, 'strike')) { + attrs[BuiltInAttributeKey.strikethrough] = true; + } + if (_containsStyle(attributes, 'underline')) { + attrs[BuiltInAttributeKey.underline] = true; + } + if (_containsStyle(attributes, 'bold')) { + attrs[BuiltInAttributeKey.bold] = true; + } + if (_containsStyle(attributes, 'italic')) { + attrs[BuiltInAttributeKey.italic] = true; + } + final link = attributes?['link'] as String?; + if (link != null) { + attrs[BuiltInAttributeKey.href] = link; + } + final color = attributes?['color'] as String?; + final colorHex = _convertColorToHexString(color); + if (colorHex != null) { + attrs[BuiltInAttributeKey.color] = colorHex; + } + final backgroundColor = attributes?['background'] as String?; + final backgroundHex = _convertColorToHexString(backgroundColor); + if (backgroundHex != null) { + attrs[BuiltInAttributeKey.backgroundColor] = backgroundHex; + } + + textNode.delta.insert(text, attributes: attrs); + } + + bool _containsStyle(Map? attributes, String key) { + final value = attributes?[key] as bool?; + return value == true; + } + + String? _convertColorToHexString(String? color) { + if (color == null) { + return null; + } + if (color.startsWith('#')) { + return '0xFF${color.substring(1)}'; + } else if (color.startsWith("rgba")) { + List rgbaList = color.substring(5, color.length - 1).split(','); + return Color.fromRGBO( + int.parse(rgbaList[0]), + int.parse(rgbaList[1]), + int.parse(rgbaList[2]), + double.parse(rgbaList[3]), + ).toHex(); + } + return null; + } + + // convert bullet-list, number-list, check-list to appflowy style list. + void _applyListStyle(TextNode textNode, Map? attributes) { + final indent = attributes?['indent'] as int?; + final list = attributes?['list'] as String?; + if (list != null) { + switch (list) { + case 'bullet': + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.bulletedList, + }); + if (indent != null) { + _bulletedList[indent] ??= []; + _bulletedList[indent]?.add(textNode); + } else { + _bulletedList.clear(); + _bulletedList[0] ??= []; + _bulletedList[0]?.add(textNode); + } + break; + case 'ordered': + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.numberList, + BuiltInAttributeKey.number: _number++, + }); + break; + case 'checked': + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: true, + }); + break; + case 'unchecked': + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox, + BuiltInAttributeKey.checkbox: false, + }); + break; + } + } + } + + bool _containNumberListStyle(Map? attributes) { + final list = attributes?['list'] as String?; + return list == 'ordered'; + } + + bool _containIndentBulletedListStyle(Map? attributes) { + final list = attributes?['list'] as String?; + final indent = attributes?['indent'] as int?; + return list == 'bullet' && indent != null; + } + + int _indentLevel(Map? attributes) { + final indent = attributes?['indent'] as int?; + return indent ?? 1; + } + + // convert header to appflowy style heading + void _applyHeaderStyle(TextNode textNode, Map? attributes) { + final header = attributes?['header'] as int?; + if (header != null) { + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading, + BuiltInAttributeKey.heading: 'h$header', + }); + } + } + + // convert indent to tab + void _applyIndent(TextNode textNode, Map? attributes) { + final indent = attributes?['indent'] as int?; + final list = attributes?['list'] as String?; + if (indent != null && list == null) { + textNode.delta = textNode.delta.compose( + Delta() + ..retain(0) + ..insert(' ' * indent), + ); + } + } + + /* + // convert code-block to appflowy style code + void _applyCodeBlock(TextNode textNode, Map? attributes) { + final codeBlock = attributes?['code-block'] as bool?; + if (codeBlock != null) { + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: 'code_block', + }); + } + } + */ + + void _applyBlockquote(TextNode textNode, Map? attributes) { + final blockquote = attributes?['blockquote'] as bool?; + if (blockquote != null) { + textNode.updateAttributes({ + BuiltInAttributeKey.subtype: BuiltInAttributeKey.quote, + }); + } + } +} + +extension on Color { + String toHex() { + return '0x${value.toRadixString(16)}'; + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/plugins/quill_delta/delta_document_encoder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/plugins/quill_delta/delta_document_encoder_test.dart new file mode 100644 index 0000000000..1fa4b3d95d --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/plugins/quill_delta/delta_document_encoder_test.dart @@ -0,0 +1,551 @@ +import 'dart:convert'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + group('delta_document_encoder.dart', () { + test('', () { + final json = jsonDecode(quillDeltaSample.replaceAll('\\\\\n', '\\n')); + final document = DeltaDocumentConvert().convertFromJSON(json); + expect(jsonEncode(document.toJson()), documentSample); + }); + }); +} + +const documentSample = + r'''{"document":{"type":"editor","children":[{"type":"text","attributes":{"subtype":"heading","heading":"h1"},"delta":[{"insert":"Flutter Quill"}]},{"type":"text","delta":[]},{"type":"text","attributes":{"subtype":"heading","heading":"h2"},"delta":[{"insert":"Rich text editor for Flutter"}]},{"type":"text","attributes":{"subtype":"heading","heading":"h3"},"delta":[{"insert":"Quill component for Flutter"}]},{"type":"text","delta":[{"insert":"This "},{"insert":"library","attributes":{"italic":true}},{"insert":" supports "},{"insert":"mobile","attributes":{"bold":true,"backgroundColor":"0xFFebd6ff"}},{"insert":" platform "},{"insert":"only","attributes":{"underline":true,"bold":true,"color":"0xFFe60000"}},{"insert":" and ","attributes":{"color":"0xd7000000"}},{"insert":"web","attributes":{"strikethrough":true}},{"insert":" is not supported."}]},{"type":"text","delta":[{"insert":"You are welcome to use "},{"insert":"Bullet Journal","attributes":{"href":"https://bulletjournal.us/home/index.html"}},{"insert":":"}]},{"type":"text","attributes":{"subtype":"number-list","number":1},"delta":[{"insert":"Track personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders"}]},{"type":"text","attributes":{"subtype":"number-list","number":2},"delta":[{"insert":"Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices"}]},{"type":"text","attributes":{"subtype":"number-list","number":3},"delta":[{"insert":"Check out what you and your teammates are working on each day"}]},{"type":"text","delta":[]},{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"Splitting bills with friends can never be easier."}]},{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"Start creating a group and invite your friends to join."}]},{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"Create a BuJo of Ledger type to see expense or balance summary."}]},{"type":"text","delta":[]},{"type":"text","attributes":{"subtype":"quote"},"delta":[{"insert":"Attach one or multiple labels to tasks, notes or transactions. Later you can track them just using the label(s)."}]},{"type":"text","delta":[]},{"type":"text","delta":[{"insert":"var BuJo = 'Bullet' + 'Journal'"}]},{"type":"text","delta":[]},{"type":"text","delta":[{"insert":" Start tracking in your browser"}]},{"type":"text","delta":[{"insert":" Stop the timer on your phone"}]},{"type":"text","delta":[{"insert":" All your time entries are synced"}]},{"type":"text","delta":[{"insert":" between the phone apps"}]},{"type":"text","delta":[{"insert":" and the website."}]},{"type":"text","delta":[]},{"type":"text","delta":[]},{"type":"text","delta":[{"insert":"Center Align"}]},{"type":"text","delta":[{"insert":"Right Align"}]},{"type":"text","delta":[{"insert":"Justify Align"}]},{"type":"text","attributes":{"subtype":"number-list","number":1},"delta":[{"insert":"Have trouble finding things? "}]},{"type":"text","attributes":{"subtype":"number-list","number":2},"delta":[{"insert":"Just type in the search bar"}]},{"type":"text","attributes":{"subtype":"number-list","number":3},"delta":[{"insert":"and easily find contents"}]},{"type":"text","attributes":{"subtype":"number-list","number":4},"delta":[{"insert":"across projects or folders."}]},{"type":"text","attributes":{"subtype":"number-list","number":5},"delta":[{"insert":"It matches text in your note or task."}]},{"type":"text","attributes":{"subtype":"number-list","number":6},"delta":[{"insert":"Enable reminders so that you will get notified by"}]},{"type":"text","attributes":{"subtype":"number-list","number":7},"delta":[{"insert":"email"}]},{"type":"text","attributes":{"subtype":"number-list","number":8},"delta":[{"insert":"message on your phone"}]},{"type":"text","attributes":{"subtype":"number-list","number":9},"delta":[{"insert":"popup on the web site"}]},{"type":"text","children":[{"type":"text","children":[{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"tasks"}]},{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"notes"}]},{"type":"text","children":[{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"under BuJo "}]}],"attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"transactions"}]}],"attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"Organize your"}]}],"attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"Create a BuJo serving as project or folder"}]},{"type":"text","children":[{"type":"text","attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"or hierarchical view"}]}],"attributes":{"subtype":"bulleted-list"},"delta":[{"insert":"See them in Calendar"}]},{"type":"text","attributes":{"subtype":"checkbox","checkbox":true},"delta":[{"insert":"this is a check list"}]},{"type":"text","attributes":{"subtype":"checkbox","checkbox":false},"delta":[{"insert":"this is a uncheck list"}]},{"type":"text","delta":[{"insert":"Font Sans Serif Serif Monospace Size Small Large Hugefont size 15 font size 35 font size 20 diff-match-patch"}]},{"type":"text","delta":[{"insert":""}]}]}}'''; + +const quillDeltaSample = r''' +[ + { + "insert": "Flutter Quill" + }, + { + "attributes": { + "header": 1 + }, + "insert": "\n" + }, + { + "insert": { + "video": "https://www.youtube.com/watch?v=V4hgdKhIqtc&list=PLbhaS_83B97s78HsDTtplRTEhcFsqSqIK&index=1" + } + }, + { + "insert": { + "video": "https://user-images.githubusercontent.com/122956/126238875-22e42501-ad41-4266-b1d6-3f89b5e3b79b.mp4" + } + }, + { + "insert": "\nRich text editor for Flutter" + }, + { + "attributes": { + "header": 2 + }, + "insert": "\n" + }, + { + "insert": "Quill component for Flutter" + }, + { + "attributes": { + "header": 3 + }, + "insert": "\n" + }, + { + "insert": "This " + }, + { + "attributes": { + "italic": true, + "background": "transparent" + }, + "insert": "library" + }, + { + "insert": " supports " + }, + { + "attributes": { + "bold": true, + "background": "#ebd6ff" + }, + "insert": "mobile" + }, + { + "insert": " platform " + }, + { + "attributes": { + "underline": true, + "bold": true, + "color": "#e60000" + }, + "insert": "only" + }, + { + "attributes": { + "color": "rgba(0, 0, 0, 0.847)" + }, + "insert": " and " + }, + { + "attributes": { + "strike": true, + "color": "black" + }, + "insert": "web" + }, + { + "insert": " is not supported.\nYou are welcome to use " + }, + { + "attributes": { + "link": "https://bulletjournal.us/home/index.html" + }, + "insert": "Bullet Journal" + }, + { + "insert": ":\nTrack personal and group journals (ToDo, Note, Ledger) from multiple views with timely reminders" + }, + { + "attributes": { + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "Share your tasks and notes with teammates, and see changes as they happen in real-time, across all devices" + }, + { + "attributes": { + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "Check out what you and your teammates are working on each day" + }, + { + "attributes": { + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "\nSplitting bills with friends can never be easier." + }, + { + "attributes": { + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "Start creating a group and invite your friends to join." + }, + { + "attributes": { + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "Create a BuJo of Ledger type to see expense or balance summary." + }, + { + "attributes": { + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "\nAttach one or multiple labels to tasks, notes or transactions. Later you can track them just using the label(s)." + }, + { + "attributes": { + "blockquote": true + }, + "insert": "\n" + }, + { + "insert": "\nvar BuJo = 'Bullet' + 'Journal'" + }, + { + "attributes": { + "code-block": true + }, + "insert": "\n" + }, + { + "insert": "\nStart tracking in your browser" + }, + { + "attributes": { + "indent": 1 + }, + "insert": "\n" + }, + { + "insert": "Stop the timer on your phone" + }, + { + "attributes": { + "indent": 1 + }, + "insert": "\n" + }, + { + "insert": "All your time entries are synced" + }, + { + "attributes": { + "indent": 2 + }, + "insert": "\n" + }, + { + "insert": "between the phone apps" + }, + { + "attributes": { + "indent": 2 + }, + "insert": "\n" + }, + { + "insert": "and the website." + }, + { + "attributes": { + "indent": 3 + }, + "insert": "\n" + }, + { + "insert": "\n" + }, + { + "insert": "\nCenter Align" + }, + { + "attributes": { + "align": "center" + }, + "insert": "\n" + }, + { + "insert": "Right Align" + }, + { + "attributes": { + "align": "right" + }, + "insert": "\n" + }, + { + "insert": "Justify Align" + }, + { + "attributes": { + "align": "justify" + }, + "insert": "\n" + }, + { + "insert": "Have trouble finding things? " + }, + { + "attributes": { + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "Just type in the search bar" + }, + { + "attributes": { + "indent": 1, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "and easily find contents" + }, + { + "attributes": { + "indent": 2, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "across projects or folders." + }, + { + "attributes": { + "indent": 2, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "It matches text in your note or task." + }, + { + "attributes": { + "indent": 1, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "Enable reminders so that you will get notified by" + }, + { + "attributes": { + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "email" + }, + { + "attributes": { + "indent": 1, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "message on your phone" + }, + { + "attributes": { + "indent": 1, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "popup on the web site" + }, + { + "attributes": { + "indent": 1, + "list": "ordered" + }, + "insert": "\n" + }, + { + "insert": "Create a BuJo serving as project or folder" + }, + { + "attributes": { + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "Organize your" + }, + { + "attributes": { + "indent": 1, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "tasks" + }, + { + "attributes": { + "indent": 2, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "notes" + }, + { + "attributes": { + "indent": 2, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "transactions" + }, + { + "attributes": { + "indent": 2, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "under BuJo " + }, + { + "attributes": { + "indent": 3, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "See them in Calendar" + }, + { + "attributes": { + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "or hierarchical view" + }, + { + "attributes": { + "indent": 1, + "list": "bullet" + }, + "insert": "\n" + }, + { + "insert": "this is a check list" + }, + { + "attributes": { + "list": "checked" + }, + "insert": "\n" + }, + { + "insert": "this is a uncheck list" + }, + { + "attributes": { + "list": "unchecked" + }, + "insert": "\n" + }, + { + "insert": "Font " + }, + { + "attributes": { + "font": "sans-serif" + }, + "insert": "Sans Serif" + }, + { + "insert": " " + }, + { + "attributes": { + "font": "serif" + }, + "insert": "Serif" + }, + { + "insert": " " + }, + { + "attributes": { + "font": "monospace" + }, + "insert": "Monospace" + }, + { + "insert": " Size " + }, + { + "attributes": { + "size": "small" + }, + "insert": "Small" + }, + { + "insert": " " + }, + { + "attributes": { + "size": "large" + }, + "insert": "Large" + }, + { + "insert": " " + }, + { + "attributes": { + "size": "huge" + }, + "insert": "Huge" + }, + { + "attributes": { + "size": "15.0" + }, + "insert": "font size 15" + }, + { + "insert": " " + }, + { + "attributes": { + "size": "35" + }, + "insert": "font size 35" + }, + { + "insert": " " + }, + { + "attributes": { + "size": "20" + }, + "insert": "font size 20" + }, + { + "attributes": { + "token": "built_in" + }, + "insert": " diff" + }, + { + "attributes": { + "token": "operator" + }, + "insert": "-match" + }, + { + "attributes": { + "token": "literal" + }, + "insert": "-patch" + }, + { + "insert": { + "image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" + }, + "attributes": { + "width": "230", + "style": "display: block; margin: auto;" + } + }, + { + "insert": "\n" + } +] +''';