From 522f17a00d7d2d529192745bf97a107b9e85d699 Mon Sep 17 00:00:00 2001 From: Ali Hassan <71745182+alihassan143@users.noreply.github.com> Date: Sun, 22 Oct 2023 18:59:26 +0500 Subject: [PATCH] feat: table html encoder and decoder added (#449) * feat: table html encoder and decoder added * feat: html table encoder test added * feat: fix the test ran issues * fix: removed unused code --- .../html/encoder/parser/html_parser.dart | 1 + .../encoder/parser/table_node_parser.dart | 69 +++++++++ lib/src/plugins/html/html_document.dart | 1 + .../plugins/html/html_document_decoder.dart | 138 ++++++++++++++++++ .../decoder/document_html_decoder_test.dart | 125 ++++++++++++++++ .../encoder/document_html_encoder_test.dart | 1 + .../parser/image_node_parser_test.dart | 1 + .../parser/table_node_parser_test.dart | 116 +++++++++++++++ .../encoder/parser/text_node_parser_test.dart | 2 +- 9 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 lib/src/plugins/html/encoder/parser/table_node_parser.dart create mode 100644 test/plugins/html/encoder/parser/table_node_parser_test.dart diff --git a/lib/src/plugins/html/encoder/parser/html_parser.dart b/lib/src/plugins/html/encoder/parser/html_parser.dart index e394f24dc..d995c894d 100644 --- a/lib/src/plugins/html/encoder/parser/html_parser.dart +++ b/lib/src/plugins/html/encoder/parser/html_parser.dart @@ -4,5 +4,6 @@ export 'html_node_parser.dart'; export 'image_node_parser.dart'; export 'numbered_list_node_parser.dart'; export 'quote_node_parser.dart'; +export 'table_node_parser.dart'; export 'text_node_parser.dart'; export 'todo_list_node_parser.dart'; diff --git a/lib/src/plugins/html/encoder/parser/table_node_parser.dart b/lib/src/plugins/html/encoder/parser/table_node_parser.dart new file mode 100644 index 000000000..682cf1ef7 --- /dev/null +++ b/lib/src/plugins/html/encoder/parser/table_node_parser.dart @@ -0,0 +1,69 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:html/dom.dart' as dom; + +import '../../../../editor/block_component/table_block_component/util.dart'; + +class HtmlTableNodeParser extends HTMLNodeParser { + const HtmlTableNodeParser(); + + @override + String get id => TableBlockKeys.type; + + @override + String transformNodeToHTMLString( + Node node, { + required List encodeParsers, + }) { + assert(node.type == TableBlockKeys.type); + + return toHTMLString( + transformNodeToDomNodes(node, encodeParsers: encodeParsers), + ); + } + + @override + List transformNodeToDomNodes( + Node node, { + required List encodeParsers, + }) { + final int rowsLen = node.attributes[TableBlockKeys.rowsLen], + colsLen = node.attributes[TableBlockKeys.colsLen]; + final List domNodes = []; + + for (var i = 0; i < rowsLen; i++) { + final List nodes = []; + for (var j = 0; j < colsLen; j++) { + final Node cell = getCellNode(node, j, i)!; + + for (final childnode in cell.children) { + HTMLNodeParser? parser = encodeParsers.firstWhereOrNull( + (element) => element.id == childnode.type, + ); + + if (parser != null) { + nodes.add( + wrapChildrenNodesWithTagName( + HTMLTags.tabledata, + childNodes: parser.transformNodeToDomNodes( + childnode, + encodeParsers: encodeParsers, + ), + ), + ); + } + } + } + final rowelement = + wrapChildrenNodesWithTagName(HTMLTags.tableRow, childNodes: nodes); + + domNodes.add(rowelement); + } + + final element = + wrapChildrenNodesWithTagName(HTMLTags.table, childNodes: domNodes); + return [ + element, + ]; + } +} diff --git a/lib/src/plugins/html/html_document.dart b/lib/src/plugins/html/html_document.dart index bb57c3280..dc359cb4d 100644 --- a/lib/src/plugins/html/html_document.dart +++ b/lib/src/plugins/html/html_document.dart @@ -28,6 +28,7 @@ String documentToHTML( const HTMLQuoteNodeParser(), const HTMLHeadingNodeParser(), const HTMLImageNodeParser(), + const HtmlTableNodeParser() ], ).encode(document); } diff --git a/lib/src/plugins/html/html_document_decoder.dart b/lib/src/plugins/html/html_document_decoder.dart index d8eaca1d0..9a8852e5a 100644 --- a/lib/src/plugins/html/html_document_decoder.dart +++ b/lib/src/plugins/html/html_document_decoder.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'dart:convert'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/block_component/table_block_component/table_node.dart'; import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' show parse; @@ -78,6 +79,8 @@ class DocumentHTMLDecoder extends Converter { return _parseUnOrderListElement(element); case HTMLTags.orderedList: return _parseOrderListElement(element); + case HTMLTags.table: + return _parseTable(element); case HTMLTags.list: return [ _parseListElement( @@ -96,6 +99,134 @@ class DocumentHTMLDecoder extends Converter { } } + Iterable _parseTable(dom.Element element) { + final List tablenodes = []; + int columnLenth = 0; + int rowLength = 0; + for (final data in element.children) { + final (col, row, rwdata) = _parsetableRows(data); + columnLenth = columnLenth + col; + rowLength = rowLength + row; + + tablenodes.addAll(rwdata); + } + + return [ + TableNode( + node: Node( + type: TableBlockKeys.type, + attributes: { + TableBlockKeys.rowsLen: rowLength, + TableBlockKeys.colsLen: columnLenth, + TableBlockKeys.colDefaultWidth: TableDefaults.colWidth, + TableBlockKeys.rowDefaultHeight: TableDefaults.rowHeight, + TableBlockKeys.colMinimumWidth: TableDefaults.colMinimumWidth, + }, + children: tablenodes, + ), + ).node, + ]; + } + + (int, int, List) _parsetableRows(dom.Element element) { + final List nodes = []; + int colLength = 0; + int rowLength = 0; + + for (final data in element.children) { + final tabledata = _parsetableData(data, rowPosition: rowLength); + if (colLength == 0) { + colLength = tabledata.length; + } + nodes.addAll(tabledata); + rowLength++; + } + return (colLength, rowLength, nodes); + } + + Iterable _parsetableData( + dom.Element element, { + required int rowPosition, + }) { + final List nodes = []; + int columnPosition = 0; + + for (final data in element.children) { + Attributes attributes = { + TableCellBlockKeys.colPosition: columnPosition, + TableCellBlockKeys.rowPosition: rowPosition, + }; + if (data.attributes.isNotEmpty) { + final deltaAttributes = _getDeltaAttributesFromHTMLAttributes( + element.attributes, + ) ?? + {}; + attributes.addAll(deltaAttributes); + } + + List children; + if (data.children.isEmpty) { + children = [paragraphNode(text: data.text)]; + } else { + children = _parseTableSpecialNodes(data).toList(); + } + + final node = Node( + type: TableCellBlockKeys.type, + attributes: attributes, + children: children, + ); + + nodes.add(node); + columnPosition++; + } + + return nodes; + } + + Iterable _parseTableSpecialNodes(dom.Element element) { + final List nodes = []; + + if (element.children.isNotEmpty) { + for (final childrens in element.children) { + nodes.addAll(_parseTableDataElementsData(childrens)); + } + } else { + nodes.addAll(_parseTableDataElementsData(element)); + } + return nodes; + } + + List _parseTableDataElementsData(dom.Element element) { + final List nodes = []; + final delta = Delta(); + final localName = element.localName; + + if (HTMLTags.formattingElements.contains(localName)) { + final attributes = _parserFormattingElementAttributes(element); + delta.insert(element.text, attributes: attributes); + } else if (HTMLTags.specialElements.contains(localName)) { + if (delta.isNotEmpty) { + nodes.add(paragraphNode(delta: delta)); + } + nodes.addAll( + _parseSpecialElements( + element, + type: ParagraphBlockKeys.type, + ), + ); + } else if (element is dom.Text) { + // skip the empty text node + + delta.insert(element.text); + } + + if (delta.isNotEmpty) { + nodes.add(paragraphNode(delta: delta)); + } + return nodes; + } + Attributes _parserFormattingElementAttributes( dom.Element element, ) { @@ -130,6 +261,7 @@ class DocumentHTMLDecoder extends Converter { attributes = {AppFlowyRichTextKeys.href: href}; } break; + case HTMLTags.strikethrough: attributes = {AppFlowyRichTextKeys.strikethrough: true}; break; @@ -360,6 +492,10 @@ class HTMLTags { static const blockQuote = 'blockquote'; static const div = 'div'; static const divider = 'hr'; + static const table = 'table'; + static const tableRow = 'tr'; + static const tableheader = "th"; + static const tabledata = "td"; static const section = 'section'; static const font = 'font'; static const mark = 'mark'; @@ -387,6 +523,7 @@ class HTMLTags { HTMLTags.orderedList, HTMLTags.div, HTMLTags.list, + HTMLTags.table, HTMLTags.paragraph, HTMLTags.blockQuote, HTMLTags.checkbox, @@ -398,6 +535,7 @@ class HTMLTags { return tag == h1 || tag == h2 || tag == h3 || + tag == table || tag == checkbox || tag == paragraph || tag == div || diff --git a/test/plugins/html/decoder/document_html_decoder_test.dart b/test/plugins/html/decoder/document_html_decoder_test.dart index 24ebbfe07..58c48c717 100644 --- a/test/plugins/html/decoder/document_html_decoder_test.dart +++ b/test/plugins/html/decoder/document_html_decoder_test.dart @@ -12,6 +12,11 @@ void main() async { expect(result.toJson(), example); }); + test('html table parser document', () async { + final result = DocumentHTMLDecoder().convert(htmlTable); + + expect(result.toJson(), htmlTablejson); + }); test('nested parser document', () async { final result = DocumentHTMLDecoder().convert(nestedHTML); expect(result.toJson(), nestedDelta); @@ -21,6 +26,126 @@ void main() async { const rawHTML = '''

AppFlowyEditor

👋 Welcome to AppFlowy Editor

AppFlowy Editor is a highly customizable rich-text editor

Here is an example your you can give a try

Span element

Span element two

Span element three

This is an anchor tag!

Features!

  • [x] Customizable
  • [x] Test-covered
  • [ ] more to come!
  • First item
  • Second item
  • List element
This is a quote!

Code block

Italic one

Italic two

Bold tag

You can also use AppFlowy Editor as a component to build your own app.

Awesome features

If you have questions or feedback, please submit an issue on Github or join the community along with 1000+ builders!

'''; +const htmlTable = + ''''

a

c

b

d

'''; +const htmlTablejson = { + "document": { + "type": "page", + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + {"insert": "'"}, + ], + }, + }, + { + "type": "table", + "children": [ + { + "type": "table/cell", + "children": [ + { + "type": "heading", + "data": { + "delta": [ + {"insert": "a"}, + ], + "level": 2, + }, + } + ], + "data": { + "colPosition": 0, + "rowPosition": 0, + "height": 40.0, + "width": 80.0, + }, + }, + { + "type": "table/cell", + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "c", + "attributes": {"italic": true}, + } + ], + }, + } + ], + "data": { + "colPosition": 1, + "rowPosition": 0, + "height": 40.0, + "width": 80.0, + }, + }, + { + "type": "table/cell", + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + { + "insert": "b", + "attributes": {"bold": true}, + } + ], + }, + } + ], + "data": { + "colPosition": 0, + "rowPosition": 1, + "height": 40.0, + "width": 80.0, + }, + }, + { + "type": "table/cell", + "children": [ + { + "type": "paragraph", + "data": { + "delta": [ + {"insert": "d"}, + ], + }, + } + ], + "data": { + "colPosition": 1, + "rowPosition": 1, + "height": 40.0, + "width": 80.0, + }, + } + ], + "data": { + "rowsLen": 2, + "colsLen": 2, + "colDefaultWidth": 80, + "rowDefaultHeight": 40, + "colMinimumWidth": 40, + }, + }, + { + "type": "paragraph", + "data": { + "delta": [ + {"insert": "'"}, + ], + }, + } + ], + }, +}; const example = { 'document': { diff --git a/test/plugins/html/encoder/document_html_encoder_test.dart b/test/plugins/html/encoder/document_html_encoder_test.dart index 04232f5bd..b499d9ac6 100644 --- a/test/plugins/html/encoder/document_html_encoder_test.dart +++ b/test/plugins/html/encoder/document_html_encoder_test.dart @@ -10,6 +10,7 @@ void main() async { const HTMLQuoteNodeParser(), const HTMLHeadingNodeParser(), const HTMLImageNodeParser(), + const HtmlTableNodeParser() ]; group('document_html_encoder_test.dart', () { setUpAll(() { diff --git a/test/plugins/html/encoder/parser/image_node_parser_test.dart b/test/plugins/html/encoder/parser/image_node_parser_test.dart index 6f2d9e447..5ff0e036e 100644 --- a/test/plugins/html/encoder/parser/image_node_parser_test.dart +++ b/test/plugins/html/encoder/parser/image_node_parser_test.dart @@ -10,6 +10,7 @@ void main() async { const HTMLQuoteNodeParser(), const HTMLHeadingNodeParser(), const HTMLImageNodeParser(), + const HtmlTableNodeParser() ]; group('html_image_node_parser.dart', () { test('parser image node', () { diff --git a/test/plugins/html/encoder/parser/table_node_parser_test.dart b/test/plugins/html/encoder/parser/table_node_parser_test.dart new file mode 100644 index 000000000..ba7550693 --- /dev/null +++ b/test/plugins/html/encoder/parser/table_node_parser_test.dart @@ -0,0 +1,116 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/block_component/table_block_component/table_node.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + List parser = [ + const HTMLTextNodeParser(), + const HTMLBulletedListNodeParser(), + const HTMLNumberedListNodeParser(), + const HTMLTodoListNodeParser(), + const HTMLQuoteNodeParser(), + const HTMLHeadingNodeParser(), + const HTMLImageNodeParser(), + const HtmlTableNodeParser() + ]; + group('html_image_node_parser.dart', () { + test('table node parser test', () { + final tableNode = TableNode.fromJson({ + 'type': TableBlockKeys.type, + 'data': { + TableBlockKeys.colsLen: 2, + TableBlockKeys.rowsLen: 2, + TableBlockKeys.colDefaultWidth: 60, + TableBlockKeys.rowDefaultHeight: 50, + TableBlockKeys.colMinimumWidth: 30, + }, + 'children': [ + { + 'type': TableCellBlockKeys.type, + 'data': { + TableCellBlockKeys.colPosition: 0, + TableCellBlockKeys.rowPosition: 0, + TableCellBlockKeys.width: 35, + }, + 'children': [ + { + 'type': 'heading', + 'data': { + 'level': 2, + 'delta': [ + {'insert': 'a'}, + ], + }, + } + ], + }, + { + 'type': TableCellBlockKeys.type, + 'data': { + TableCellBlockKeys.colPosition: 0, + TableCellBlockKeys.rowPosition: 1, + }, + 'children': [ + { + 'type': 'paragraph', + 'data': { + 'delta': [ + { + 'insert': 'b', + 'attributes': {'bold': true}, + } + ], + }, + } + ], + }, + { + 'type': TableCellBlockKeys.type, + 'data': { + TableCellBlockKeys.colPosition: 1, + TableCellBlockKeys.rowPosition: 0, + }, + 'children': [ + { + 'type': 'paragraph', + 'data': { + 'delta': [ + { + 'insert': 'c', + 'attributes': {'italic': true}, + } + ], + }, + } + ], + }, + { + 'type': TableCellBlockKeys.type, + 'data': { + TableCellBlockKeys.colPosition: 1, + TableCellBlockKeys.rowPosition: 1, + }, + 'children': [ + { + 'type': 'paragraph', + 'data': { + 'delta': [ + {'insert': 'd'}, + ], + }, + } + ], + } + ], + }); + + expect( + const HtmlTableNodeParser().transformNodeToHTMLString( + tableNode.node, + encodeParsers: parser, + ), + '''

a

c

b

d

''', + ); + }); + }); +} diff --git a/test/plugins/html/encoder/parser/text_node_parser_test.dart b/test/plugins/html/encoder/parser/text_node_parser_test.dart index 0166cdbce..5a9f2be7b 100644 --- a/test/plugins/html/encoder/parser/text_node_parser_test.dart +++ b/test/plugins/html/encoder/parser/text_node_parser_test.dart @@ -10,6 +10,7 @@ void main() async { const HTMLQuoteNodeParser(), const HTMLHeadingNodeParser(), const HTMLImageNodeParser(), + const HtmlTableNodeParser(), ]; group('html_text_node_parser.dart', () { const text = 'Welcome to AppFlowy'; @@ -240,7 +241,6 @@ void main() async { .toJson(), }, ); - expect( const HTMLTextNodeParser() .transformNodeToHTMLString(node, encodeParsers: parser),