feat: support pasting table from notion, google docs and google sheet (#7247)

* feat: support pasting table from Notion

* test: add google docs / googles sheets table test

* fix: google docs test

* fix: paste table from notion test
This commit is contained in:
Lucas 2025-01-21 09:12:57 +08:00 committed by GitHub
parent 187f7409ce
commit 8fdc6e9638
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 511 additions and 8 deletions

View File

@ -1,15 +1,11 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:html2md/html2md.dart' as html2md;
extension PasteFromHtml on EditorState {
Future<bool> pasteHtml(String html) async {
final nodes = htmlToDocument(html).root.children.toList();
// remove the front and back empty line
while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) {
nodes.removeAt(0);
}
while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) {
nodes.removeLast();
}
final nodes = convertHtmlToNodes(html);
// if there's no nodes being converted successfully, return false
if (nodes.isEmpty) {
return false;
@ -21,4 +17,89 @@ extension PasteFromHtml on EditorState {
}
return true;
}
// Convert the html to document nodes.
// For the google docs table, it will be fallback to the markdown parser.
List<Node> convertHtmlToNodes(String html) {
List<Node> nodes = htmlToDocument(html).root.children.toList();
// 1. remove the front and back empty line
while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) {
nodes.removeAt(0);
}
while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) {
nodes.removeLast();
}
// 2. replace the legacy table nodes with the new simple table nodes
for (int i = 0; i < nodes.length; i++) {
final node = nodes[i];
if (node.type == TableBlockKeys.type) {
nodes[i] = _convertTableToSimpleTable(node);
}
}
// 3. verify the nodes is empty or contains google table flag
// The table from Google Docs will contain the flag 'Google Table'
const googleDocsFlag = 'docs-internal-guid-';
final isPasteFromGoogleDocs = html.contains(googleDocsFlag);
if (nodes.isEmpty || isPasteFromGoogleDocs) {
// fallback to the markdown parser
final markdown = html2md.convert(html);
nodes = customMarkdownToDocument(markdown).root.children.toList();
}
// 4. check if the first node and the last node is bold, because google docs will wrap the table with bold tags
if (isPasteFromGoogleDocs) {
if (nodes.isNotEmpty && nodes.first.delta?.toPlainText() == '**') {
nodes.removeAt(0);
}
if (nodes.isNotEmpty && nodes.last.delta?.toPlainText() == '**') {
nodes.removeLast();
}
}
return nodes;
}
// convert the legacy table node to the new simple table node
// from type 'table' to type 'simple_table'
Node _convertTableToSimpleTable(Node node) {
if (node.type != TableBlockKeys.type) {
return node;
}
// the table node should contains colsLen and rowsLen
final colsLen = node.attributes[TableBlockKeys.colsLen];
final rowsLen = node.attributes[TableBlockKeys.rowsLen];
if (colsLen == null || rowsLen == null) {
return node;
}
final rows = <List<Node>>[];
final children = node.children;
for (var i = 0; i < rowsLen; i++) {
final row = <Node>[];
for (var j = 0; j < colsLen; j++) {
final cell = children
.where(
(n) =>
n.attributes[TableCellBlockKeys.rowPosition] == i &&
n.attributes[TableCellBlockKeys.colPosition] == j,
)
.firstOrNull;
row.add(
simpleTableCellBlockNode(
children: cell?.children.map((e) => e.deepCopy()).toList() ??
[paragraphNode()],
),
);
}
rows.add(row);
}
return simpleTableBlockNode(
children: rows.map((e) => simpleTableRowBlockNode(children: e)).toList(),
);
}
}

View File

@ -998,6 +998,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.15.5"
html2md:
dependency: "direct main"
description:
name: html2md
sha256: "465cf8ffa1b510fe0e97941579bf5b22e2d575f2cecb500a9c0254efe33a8036"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
http:
dependency: "direct main"
description:

View File

@ -87,6 +87,7 @@ dependencies:
highlight: ^0.7.0
hive_flutter: ^1.1.0
hotkey_manager: ^0.1.7
html2md: ^1.3.2
http: ^1.0.0
image_picker: ^1.0.4

View File

@ -0,0 +1,361 @@
// | Month | Savings |
// | -------- | ------- |
// | January | $250 |
// | February | $80 |
// | March | $420 |
const tableFromNotion = '''
<meta charset="utf-8" />
<table id="18196b61-6923-80d7-a184-fbc0352dabc3" class="simple-table">
<tbody>
<tr id="18196b61-6923-80a7-b70a-cbae038e1472">
<td id="Wi`b" class="">Month</td>
<td id="|EyR" class="">Savings</td>
</tr>
<tr id="18196b61-6923-804a-914e-e45f6086a714">
<td id="Wi`b" class="">January</td>
<td id="|EyR" class="">\$250</td>
</tr>
<tr id="18196b61-6923-80b1-bef5-e15e1d302dfd">
<td id="Wi`b" class="">February</td>
<td id="|EyR" class="">\$80</td>
</tr>
<tr id="18196b61-6923-8079-aefa-d96c17230695">
<td id="Wi`b" class="">March</td>
<td id="|EyR" class="">\$420</td>
</tr>
</tbody>
</table>
''';
// | Month | Savings |
// | -------- | ------- |
// | January | $250 |
// | February | $80 |
// | March | $420 |
const tableFromGoogleDocs = '''
<meta charset="utf-8" /><meta charset="utf-8" />
<b style="font-weight: normal;" id="docs-internal-guid-c1bfdd24-7fff-ad47-657e-45aa52e41e4c">
<div dir="ltr" style="margin-left: 0pt;" align="left">
<table style="border: none; border-collapse: collapse; table-layout: fixed; width: 451.27559055118115pt;">
<colgroup>
<col />
<col />
</colgroup>
<tbody>
<tr style="height: 0pt;">
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
Month
</span>
</p>
</td>
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
Savings
</span>
</p>
</td>
</tr>
<tr style="height: 0pt;">
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
January
</span>
</p>
</td>
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
\$250
</span>
</p>
</td>
</tr>
<tr style="height: 0pt;">
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
February
</span>
</p>
</td>
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
\$80
</span>
</p>
</td>
</tr>
<tr style="height: 0pt;">
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
March
</span>
</p>
</td>
<td
style="
border-left: solid #000000 1pt;
border-right: solid #000000 1pt;
border-bottom: solid #000000 1pt;
border-top: solid #000000 1pt;
vertical-align: top;
padding: 5pt 5pt 5pt 5pt;
overflow: hidden;
overflow-wrap: break-word;
"
>
<p dir="ltr" style="line-height: 1.2; margin-top: 0pt; margin-bottom: 0pt;">
<span
style="
font-size: 11pt;
font-family: Arial, sans-serif;
color: #000000;
background-color: transparent;
font-weight: 400;
font-style: normal;
font-variant: normal;
text-decoration: none;
vertical-align: baseline;
white-space: pre;
white-space: pre-wrap;
"
>
\$420
</span>
</p>
</td>
</tr>
</tbody>
</table>
</div>
</b>
''';
// | Month | Savings |
// | -------- | ------- |
// | January | $250 |
// | February | $80 |
// | March | $420 |
const tableFromGoogleSheets = '''
<meta charset="utf-8" />
<google-sheets-html-origin>
<style type="text/css">
<!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}-->
</style>
<table
xmlns="http://www.w3.org/1999/xhtml"
cellspacing="0"
cellpadding="0"
dir="ltr"
border="1"
style="table-layout: fixed; font-size: 10pt; font-family: Arial; width: 0px; border-collapse: collapse; border: none;"
data-sheets-root="1"
data-sheets-baot="1"
>
<colgroup>
<col width="100" />
<col width="100" />
</colgroup>
<tbody>
<tr style="height: 21px;">
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">Month</td>
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">Savings</td>
</tr>
<tr style="height: 21px;">
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">January</td>
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">\$250</td>
</tr>
<tr style="height: 21px;">
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">February</td>
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">\$80</td>
</tr>
<tr style="height: 21px;">
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">March</td>
<td style="overflow: hidden; padding: 2px 3px 2px 3px; vertical-align: bottom;">\$420</td>
</tr>
</tbody>
</table>
</google-sheets-html-origin>
''';

View File

@ -0,0 +1,52 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import '_html_samples.dart';
void main() {
group('paste from html:', () {
void checkTable(String html) {
final nodes = EditorState.blank().convertHtmlToNodes(html);
expect(nodes.length, 1);
final table = nodes.first;
expect(table.type, SimpleTableBlockKeys.type);
expect(table.getCellText(0, 0), 'Month');
expect(table.getCellText(0, 1), 'Savings');
expect(table.getCellText(1, 0), 'January');
expect(table.getCellText(1, 1), '\$250');
expect(table.getCellText(2, 0), 'February');
expect(table.getCellText(2, 1), '\$80');
expect(table.getCellText(3, 0), 'March');
expect(table.getCellText(3, 1), '\$420');
}
test('sample 1 - paste table from Notion', () {
checkTable(tableFromNotion);
});
test('sample 2 - paste table from Google Docs', () {
checkTable(tableFromGoogleDocs);
});
test('sample 3 - paste table from Google Sheets', () {
checkTable(tableFromGoogleSheets);
});
});
}
extension on Node {
String getCellText(
int row,
int column, {
int index = 0,
}) {
return children[row]
.children[column]
.children[index]
.delta
?.toPlainText() ??
'';
}
}