From ee25f032db3d45dba1e841bdf449008c84cf3464 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 1 Aug 2022 15:34:22 +0800 Subject: [PATCH 01/16] feat: copy & paste key event handlers --- .../lib/service/editor_service.dart | 18 ++++++++++-------- .../copy_paste_handler.dart | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index b62fe1bb15..39382d3a9c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -7,17 +7,18 @@ import 'package:flowy_editor/render/editor/editor_entry.dart'; import 'package:flowy_editor/render/rich_text/bulleted_list_text.dart'; import 'package:flowy_editor/render/rich_text/checkbox_text.dart'; import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/service/input_service.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; +import 'package:flowy_editor/service/render_plugin_service.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/copy_paste_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; +import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart'; +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/service/selection_service.dart'; import 'package:flowy_editor/render/rich_text/heading_text.dart'; import 'package:flowy_editor/render/rich_text/number_list_text.dart'; import 'package:flowy_editor/render/rich_text/quoted_text.dart'; -import 'package:flowy_editor/service/input_service.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; -import 'package:flowy_editor/service/internal_key_event_handlers/shortcut_handler.dart'; -import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/service/render_plugin_service.dart'; -import 'package:flowy_editor/service/selection_service.dart'; import 'package:flowy_editor/service/toolbar_service.dart'; NodeWidgetBuilders defaultBuilders = { @@ -35,6 +36,7 @@ List defaultKeyEventHandler = [ slashShortcutHandler, flowyDeleteNodesHandler, arrowKeysHandler, + copyPasteKeysHandler, enterInEdgeOfTextNodeHandler, updateTextStyleByCommandXHandler, ]; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart new file mode 100644 index 0000000000..52e2603dbd --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -0,0 +1,19 @@ +import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { + debugPrint("copy"); + return KeyEventResult.handled; + } + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) { + debugPrint("paste"); + return KeyEventResult.handled; + } + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyX) { + debugPrint("cut"); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; From 9ceced464817b2df1a1df77c67cd52857001da68 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 1 Aug 2022 16:14:56 +0800 Subject: [PATCH 02/16] feat: parse html --- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + .../flowy_editor/example/macos/Podfile.lock | 6 ++ .../flowy_editor/example/pubspec.lock | 86 ++++++++++++++++++- .../lib/infra/html_converter.dart | 29 +++++++ .../copy_paste_handler.dart | 41 ++++++++- .../packages/flowy_editor/pubspec.yaml | 2 + 8 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc b/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc index f6f23bfe97..00fd3bc03f 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc +++ b/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) rich_clipboard_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RichClipboardPlugin"); + rich_clipboard_plugin_register_with_registrar(rich_clipboard_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake b/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake index f16b4c3421..0342e3868a 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake +++ b/frontend/app_flowy/packages/flowy_editor/example/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + rich_clipboard_linux url_launcher_linux ) diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift index 8236f5728c..0dc858f3c7 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/app_flowy/packages/flowy_editor/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import rich_clipboard_macos import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock b/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock index 4f162e68af..93389ef3ec 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock +++ b/frontend/app_flowy/packages/flowy_editor/example/macos/Podfile.lock @@ -1,20 +1,26 @@ PODS: - FlutterMacOS (1.0.0) + - rich_clipboard_macos (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) + - rich_clipboard_macos (from `Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: FlutterMacOS: :path: Flutter/ephemeral + rich_clipboard_macos: + :path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock index cfadcb8242..8a0f4ea223 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock @@ -43,6 +43,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.2" cupertino_icons: dependency: "direct main" description: @@ -57,6 +64,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + ffi: + dependency: transitive + description: + name: ffi + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" flowy_editor: dependency: "direct main" description: @@ -93,6 +107,13 @@ packages: description: flutter source: sdk version: "0.0.0" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.0" js: dependency: transitive description: @@ -177,6 +198,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.0.3" + rich_clipboard: + dependency: transitive + description: + name: rich_clipboard + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_android: + dependency: transitive + description: + name: rich_clipboard_android + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_ios: + dependency: transitive + description: + name: rich_clipboard_ios + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_linux: + dependency: transitive + description: + name: rich_clipboard_linux + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_macos: + dependency: transitive + description: + name: rich_clipboard_macos + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + rich_clipboard_platform_interface: + dependency: transitive + description: + name: rich_clipboard_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_web: + dependency: transitive + description: + name: rich_clipboard_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + rich_clipboard_windows: + dependency: transitive + description: + name: rich_clipboard_windows + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" sky_engine: dependency: transitive description: flutter @@ -287,6 +364,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + win32: + dependency: transitive + description: + name: win32 + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" xml: dependency: transitive description: @@ -296,4 +380,4 @@ packages: version: "6.1.0" sdks: dart: ">=2.17.0 <3.0.0" - flutter: ">=2.11.0-0.1.pre" + flutter: ">=3.0.0" diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart new file mode 100644 index 0000000000..e1920555af --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -0,0 +1,29 @@ +import 'dart:collection'; + +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/text_delta.dart'; +import 'package:html/parser.dart' show parse; +import 'package:html/dom.dart' as html; + +class HTMLConverter { + final html.Document _document; + + HTMLConverter(String htmlString) : _document = parse(htmlString); + + List toNodes() { + final result = []; + final delta = Delta(); + + final bodyChildren = _document.body?.children ?? []; + for (final child in bodyChildren) { + delta.insert(child.text); + } + + if (delta.operations.isNotEmpty) { + result.add(TextNode( + type: "text", children: LinkedList(), attributes: {}, delta: delta)); + } + + return result; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 52e2603dbd..dd20f39ca9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,18 +1,53 @@ +import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/infra/html_converter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:rich_clipboard/rich_clipboard.dart'; + +_handleCopy() async { + debugPrint('copy'); +} + +_pasteHTML(EditorState editorState, String html) { + final converter = HTMLConverter(html); + final nodes = converter.toNodes(); + final selection = editorState.cursorSelection; + if (selection == null) { + return; + } + + final tb = TransactionBuilder(editorState); + for (final node in nodes) { + tb.insertNode(selection.end.path, node); + } + tb.commit(); +} + +_handlePaste(EditorState editorState) async { + final data = await RichClipboard.getData(); + if (data.html != null) { + _pasteHTML(editorState, data.html!); + return; + } + debugPrint('paste ${data.text ?? ''}'); +} + +_handleCut() { + debugPrint('cut'); +} FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { - debugPrint("copy"); + _handleCopy(); return KeyEventResult.handled; } if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) { - debugPrint("paste"); + _handlePaste(editorState); return KeyEventResult.handled; } if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyX) { - debugPrint("cut"); + _handleCut(); return KeyEventResult.handled; } return KeyEventResult.ignored; diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index db0eef5296..e3a6aab187 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: flutter: sdk: flutter + rich_clipboard: ^1.0.0 + html: ^0.15.0 flutter_svg: ^1.1.1+1 provider: ^6.0.3 From 40c3f07be431606136b603cfdff266d193f0b666 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 1 Aug 2022 18:20:10 +0800 Subject: [PATCH 03/16] feat: use patch nodes --- .../copy_paste_handler.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index dd20f39ca9..01149af9ca 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -17,10 +17,14 @@ _pasteHTML(EditorState editorState, String html) { return; } - final tb = TransactionBuilder(editorState); - for (final node in nodes) { - tb.insertNode(selection.end.path, node); + final path = [...selection.end.path]; + if (path.isEmpty) { + return; } + path[path.length - 1]++; + + final tb = TransactionBuilder(editorState); + tb.insertNodes(path, nodes); tb.commit(); } From d28321167184c0ce041fca5d21c9c563dec1ed75 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 2 Aug 2022 11:30:52 +0800 Subject: [PATCH 04/16] feat: paste multi lines text --- .../flowy_editor/lib/document/node.dart | 7 +++-- .../lib/infra/html_converter.dart | 5 +--- .../copy_paste_handler.dart | 28 ++++++++++++++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 3a7ad36456..c3f75c5c9c 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -176,10 +176,11 @@ class TextNode extends Node { TextNode({ required super.type, - required super.children, - required super.attributes, required Delta delta, - }) : _delta = delta; + LinkedList? children, + Attributes? attributes, + }) : _delta = delta, + super(children: children ?? LinkedList(), attributes: attributes ?? {}); TextNode.empty() : _delta = Delta([TextInsert(' ')]), diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index e1920555af..40687ca160 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -1,5 +1,3 @@ -import 'dart:collection'; - import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:html/parser.dart' show parse; @@ -20,8 +18,7 @@ class HTMLConverter { } if (delta.operations.isNotEmpty) { - result.add(TextNode( - type: "text", children: LinkedList(), attributes: {}, delta: delta)); + result.add(TextNode(type: "text", delta: delta)); } return result; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 01149af9ca..ca9debb2b0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -34,7 +34,33 @@ _handlePaste(EditorState editorState) async { _pasteHTML(editorState, data.html!); return; } - debugPrint('paste ${data.text ?? ''}'); + if (data.text != null) { + _handlePastePlainText(editorState, data.text!); + return; + } +} + +_handlePastePlainText(EditorState editorState, String plainText) { + final selection = editorState.cursorSelection; + if (selection == null) { + return; + } + + final path = [...selection.end.path]; + if (path.isEmpty) { + return; + } + path[path.length - 1]++; + + final lines = + plainText.split("\n").map((e) => e.replaceAll(RegExp(r'\r'), "")); + final nodes = lines + .map((e) => TextNode(type: "text", delta: Delta().insert(e))) + .toList(); + + final tb = TransactionBuilder(editorState); + tb.insertNodes(path, nodes); + tb.commit(); } _handleCut() { From 67fd06366e8830cfb59d7787761f49ae39cb69e5 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 2 Aug 2022 15:19:17 +0800 Subject: [PATCH 05/16] feat: handle HTMLElement --- .../example/lib/plugin/image_node_widget.dart | 3 +- .../lib/infra/html_converter.dart | 94 +++++++++++++++++-- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index 6a01fb6430..417d1ce11c 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -90,8 +90,7 @@ class _ImageNodeWidgetState extends State with Selectable { @override Position getPositionInOffset(Offset start) { - // TODO: implement getPositionInOffset - throw UnimplementedError(); + return Position(path: node.path, offset: 0); } @override diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 40687ca160..e39d082607 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:html/parser.dart' show parse; @@ -10,17 +12,97 @@ class HTMLConverter { List toNodes() { final result = []; - final delta = Delta(); final bodyChildren = _document.body?.children ?? []; for (final child in bodyChildren) { - delta.insert(child.text); - } - - if (delta.operations.isNotEmpty) { - result.add(TextNode(type: "text", delta: delta)); + _handleElement(result, child); } return result; } + + _handleElement(List nodes, html.Element element) { + if (element.localName == "h1") { + _handleHeadingElement(nodes, element, "h1"); + } else if (element.localName == "h2") { + _handleHeadingElement(nodes, element, "h2"); + } else if (element.localName == "h3") { + _handleHeadingElement(nodes, element, "h3"); + } else if (element.localName == "ul") { + _handleUnorderedList(nodes, element); + } else if (element.localName == "li") { + _handleListElement(nodes, element); + } else if (element.localName == "p") { + _handleParagraph(nodes, element); + } else { + final delta = Delta(); + delta.insert(element.text); + if (delta.operations.isNotEmpty) { + nodes.add(TextNode(type: "text", delta: delta)); + } + } + } + + _handleParagraph(List nodes, html.Element element) { + for (final child in element.children) { + if (child.localName == "a") { + _handleAnchorLink(nodes, child); + } + } + + final delta = Delta(); + delta.insert(element.text); + if (delta.operations.isNotEmpty) { + nodes.add(TextNode(type: "text", delta: delta)); + } + } + + _handleAnchorLink(List nodes, html.Element element) { + for (final child in element.children) { + if (child.localName == "img") { + _handleImage(nodes, child); + return; + } + } + } + + _handleImage(List nodes, html.Element element) { + final src = element.attributes["src"]; + final attributes = {}; + if (src != null) { + attributes["image_src"] = src; + } + nodes.add( + Node(type: "image", attributes: attributes, children: LinkedList())); + } + + _handleUnorderedList(List nodes, html.Element element) { + element.children.forEach((child) { + _handleListElement(nodes, child); + }); + } + + _handleHeadingElement( + List nodes, + html.Element element, + String headingStyle, + ) { + final delta = Delta(); + delta.insert(element.text); + if (delta.operations.isNotEmpty) { + nodes.add(TextNode( + type: "text", + attributes: {"subtype": "heading", "heading": headingStyle}, + delta: delta)); + } + } + + _handleListElement(List nodes, html.Element element) { + final delta = Delta(); + delta.insert(element.text); + if (delta.operations.isNotEmpty) { + nodes.add(TextNode( + type: "text", attributes: {"subtype": "bullet-list"}, delta: delta)); + } + } } From 4e3e9d1a2c0a805e83647a86471377f943baa1bf Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 2 Aug 2022 16:54:10 +0800 Subject: [PATCH 06/16] feat: paste hyper link --- .../lib/infra/html_converter.dart | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index e39d082607..608020e327 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; +import 'package:flutter/foundation.dart'; import 'package:html/parser.dart' show parse; import 'package:html/dom.dart' as html; @@ -12,10 +13,24 @@ class HTMLConverter { List toNodes() { final result = []; + final delta = Delta(); - final bodyChildren = _document.body?.children ?? []; - for (final child in bodyChildren) { - _handleElement(result, child); + for (final child in _document.body?.nodes.toList() ?? []) { + if (child is html.Element) { + if (child.localName == "span") { + delta.insert(child.text); + } else if (child.localName == "strong") { + delta.insert(child.text, {"bold": true}); + } else { + _handleElement(result, child); + } + } else { + delta.insert(child.text ?? ""); + } + } + + if (delta.operations.isNotEmpty) { + result.add(TextNode(type: "text", delta: delta)); } return result; @@ -44,34 +59,43 @@ class HTMLConverter { } _handleParagraph(List nodes, html.Element element) { - for (final child in element.children) { - if (child.localName == "a") { - _handleAnchorLink(nodes, child); + final image = element.querySelector("img"); + if (image != null) { + _handleImage(nodes, image); + return; + } + + var delta = Delta(); + + for (final child in element.nodes.toList()) { + if (child is html.Element) { + if (child.localName == "a") { + final hyperLink = child.attributes["href"]; + Map? attributes; + if (hyperLink != null) { + attributes = {"href": hyperLink}; + } + delta.insert(child.text, attributes); + } else { + delta.insert(child.text); + } + } else { + delta.insert(child.text ?? ""); } } - final delta = Delta(); - delta.insert(element.text); if (delta.operations.isNotEmpty) { nodes.add(TextNode(type: "text", delta: delta)); } } - _handleAnchorLink(List nodes, html.Element element) { - for (final child in element.children) { - if (child.localName == "img") { - _handleImage(nodes, child); - return; - } - } - } - _handleImage(List nodes, html.Element element) { final src = element.attributes["src"]; final attributes = {}; if (src != null) { attributes["image_src"] = src; } + debugPrint("insert image: $src"); nodes.add( Node(type: "image", attributes: attributes, children: LinkedList())); } From aba84a3ccdbde3c047c5c0781ecde94161e0ecd6 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 11:29:25 +0800 Subject: [PATCH 07/16] feat: paste inside the TextNode --- .../lib/operation/transaction_builder.dart | 4 ++ .../copy_paste_handler.dart | 66 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 88e0c00890..8fa67687c2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart @@ -80,6 +80,10 @@ class TransactionBuilder { add(TextEditOperation(path, delta, inverted)); } + setAfterSelection(Selection sel) { + afterSelection = sel; + } + mergeText(TextNode firstNode, TextNode secondNode, {int? firstOffset, int secondOffset = 0}) { final firstLength = firstNode.delta.length; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index ca9debb2b0..4eea454605 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -46,21 +46,61 @@ _handlePastePlainText(EditorState editorState, String plainText) { return; } - final path = [...selection.end.path]; - if (path.isEmpty) { - return; - } - path[path.length - 1]++; - - final lines = - plainText.split("\n").map((e) => e.replaceAll(RegExp(r'\r'), "")); - final nodes = lines - .map((e) => TextNode(type: "text", delta: Delta().insert(e))) + final lines = plainText + .split("\n") + .map((e) => e.replaceAll(RegExp(r'\r'), "")) .toList(); - final tb = TransactionBuilder(editorState); - tb.insertNodes(path, nodes); - tb.commit(); + if (lines.isEmpty) { + return; + } else if (lines.length == 1) { + final node = + editorState.document.nodeAtPath(selection.end.path)! as TextNode; + final beginOffset = selection.end.offset; + TransactionBuilder(editorState) + ..textEdit(node, () => Delta().retain(beginOffset).insert(lines[0])) + ..setAfterSelection(Selection.collapsed(Position( + path: selection.end.path, offset: beginOffset + lines[0].length))) + ..commit(); + } else { + final firstLine = lines[0]; + final beginOffset = selection.end.offset; + final remains = lines.sublist(1); + + final path = [...selection.end.path]; + if (path.isEmpty) { + return; + } + + final node = + editorState.document.nodeAtPath(selection.end.path)! as TextNode; + final insertedLineSuffix = node.delta.slice(beginOffset); + + path[path.length - 1]++; + var index = 0; + final tb = TransactionBuilder(editorState); + final nodes = remains.map((e) { + if (index++ == remains.length - 1) { + return TextNode( + type: "text", + delta: Delta().insert(e).addAll(insertedLineSuffix.operations)); + } + return TextNode(type: "text", delta: Delta().insert(e)); + }).toList(); + // insert first line + tb.textEdit( + node, + () => Delta() + .retain(beginOffset) + .insert(firstLine) + .delete(node.delta.length - beginOffset)); + // insert remains + tb.insertNodes(path, nodes); + tb.commit(); + + editorState.updateCursorSelection(Selection.collapsed( + Position(path: nodes.last.path, offset: lines.last.length))); + } } _handleCut() { From e73465170ae0625faff5b9f4a6402776bbbf32de Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 15:30:00 +0800 Subject: [PATCH 08/16] feat: paste html rich text inside text --- .../lib/infra/html_converter.dart | 51 ++++++++++++------- .../copy_paste_handler.dart | 27 ++++++++-- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 608020e327..ece4f6b9f4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -15,12 +15,13 @@ class HTMLConverter { final result = []; final delta = Delta(); - for (final child in _document.body?.nodes.toList() ?? []) { + final childNodes = _document.body?.nodes.toList() ?? []; + for (final child in childNodes) { if (child is html.Element) { - if (child.localName == "span") { - delta.insert(child.text); - } else if (child.localName == "strong") { - delta.insert(child.text, {"bold": true}); + if (child.localName == "a" || + child.localName == "span" || + child.localName == "strong") { + _handleRichTextElement(delta, child); } else { _handleElement(result, child); } @@ -59,6 +60,25 @@ class HTMLConverter { } _handleParagraph(List nodes, html.Element element) { + _handleRichText(nodes, element); + } + + _handleRichTextElement(Delta delta, html.Element element) { + if (element.localName == "span") { + delta.insert(element.text); + } else if (element.localName == "a") { + final hyperLink = element.attributes["href"]; + Map? attributes; + if (hyperLink != null) { + attributes = {"href": hyperLink}; + } + delta.insert(element.text, attributes); + } else if (element.localName == "strong") { + delta.insert(element.text, {"bold": true}); + } + } + + _handleRichText(List nodes, html.Element element) { final image = element.querySelector("img"); if (image != null) { _handleImage(nodes, image); @@ -69,13 +89,10 @@ class HTMLConverter { for (final child in element.nodes.toList()) { if (child is html.Element) { - if (child.localName == "a") { - final hyperLink = child.attributes["href"]; - Map? attributes; - if (hyperLink != null) { - attributes = {"href": hyperLink}; - } - delta.insert(child.text, attributes); + if (child.localName == "a" || + child.localName == "span" || + child.localName == "strong") { + _handleRichTextElement(delta, element); } else { delta.insert(child.text); } @@ -122,11 +139,11 @@ class HTMLConverter { } _handleListElement(List nodes, html.Element element) { - final delta = Delta(); - delta.insert(element.text); - if (delta.operations.isNotEmpty) { - nodes.add(TextNode( - type: "text", attributes: {"subtype": "bullet-list"}, delta: delta)); + final childNodes = element.nodes.toList(); + for (final child in childNodes) { + if (child is html.Element) { + _handleRichText(nodes, child); + } } } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 4eea454605..d7eda6b64a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -10,8 +10,6 @@ _handleCopy() async { } _pasteHTML(EditorState editorState, String html) { - final converter = HTMLConverter(html); - final nodes = converter.toNodes(); final selection = editorState.cursorSelection; if (selection == null) { return; @@ -21,9 +19,31 @@ _pasteHTML(EditorState editorState, String html) { if (path.isEmpty) { return; } - path[path.length - 1]++; + + final converter = HTMLConverter(html); + final nodes = converter.toNodes(); + + if (nodes.isEmpty) { + return; + } else if (nodes.length == 1) { + final firstNode = nodes[0]; + final nodeAtPath = editorState.document.nodeAtPath(path)!; + final tb = TransactionBuilder(editorState); + final startOffset = selection.start.offset; + if (nodeAtPath.type == "text" && firstNode.type == "text") { + final textNodeAtPath = nodeAtPath as TextNode; + final firstTextNode = firstNode as TextNode; + tb.textEdit(textNodeAtPath, + () => Delta().retain(startOffset).concat(firstTextNode.delta)); + tb.setAfterSelection(Selection.collapsed(Position( + path: path, offset: startOffset + firstTextNode.delta.length))); + } + tb.commit(); + return; + } final tb = TransactionBuilder(editorState); + path[path.length - 1]++; tb.insertNodes(path, nodes); tb.commit(); } @@ -98,6 +118,7 @@ _handlePastePlainText(EditorState editorState, String plainText) { tb.insertNodes(path, nodes); tb.commit(); + // fixme: don't set the cursor manually editorState.updateCursorSelection(Selection.collapsed( Position(path: nodes.last.path, offset: lines.last.length))); } From 290435b0eea0a8a6cb4acd76c50c39ab39197540 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 16:06:07 +0800 Subject: [PATCH 09/16] feat: paste text inside text --- .../copy_paste_handler.dart | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index d7eda6b64a..6e6e30dbf4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -37,12 +37,47 @@ _pasteHTML(EditorState editorState, String html) { () => Delta().retain(startOffset).concat(firstTextNode.delta)); tb.setAfterSelection(Selection.collapsed(Position( path: path, offset: startOffset + firstTextNode.delta.length))); + tb.commit(); } + } + + _pasteMultipleLinesInText(editorState, path, selection.start.offset, nodes); +} + +_pasteMultipleLinesInText( + EditorState editorState, List path, int offset, List nodes) { + final tb = TransactionBuilder(editorState); + + final firstNode = nodes[0]; + final nodeAtPath = editorState.document.nodeAtPath(path)!; + + if (nodeAtPath.type == "text" && firstNode.type == "text") { + // split and merge + final textNodeAtPath = nodeAtPath as TextNode; + final firstTextNode = firstNode as TextNode; + final remain = textNodeAtPath.delta.slice(offset); + + tb.textEdit( + textNodeAtPath, + () => Delta() + .retain(offset) + .delete(remain.length) + .concat(firstTextNode.delta)); + + path[path.length - 1]++; + final tailNodes = nodes.sublist(1); + if (tailNodes.last.type == "text") { + final tailTextNode = tailNodes.last as TextNode; + tailTextNode.delta = tailTextNode.delta.concat(remain); + } else if (remain.length > 0) { + tailNodes.add(TextNode(type: "text", delta: remain)); + } + + tb.insertNodes(path, tailNodes); tb.commit(); return; } - final tb = TransactionBuilder(editorState); path[path.length - 1]++; tb.insertNodes(path, nodes); tb.commit(); From 2a6412f81a53f7f96d743f8ef87618e7046fdef9 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 17:29:45 +0800 Subject: [PATCH 10/16] feat: copy from html --- .../lib/infra/html_converter.dart | 60 +++++++++++++++---- .../copy_paste_handler.dart | 39 +++++++++--- .../lib/service/selection_service.dart | 2 +- 3 files changed, 83 insertions(+), 18 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index ece4f6b9f4..6895643744 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/text_delta.dart'; import 'package:flutter/foundation.dart'; @@ -20,7 +21,8 @@ class HTMLConverter { if (child is html.Element) { if (child.localName == "a" || child.localName == "span" || - child.localName == "strong") { + child.localName == "strong" || + child.localName == "b") { _handleRichTextElement(delta, child); } else { _handleElement(result, child); @@ -63,9 +65,33 @@ class HTMLConverter { _handleRichText(nodes, element); } + Attributes? _getDeltaAttributesFromHtmlAttributes( + LinkedHashMap htmlAttributes) { + final attrs = {}; + final styleString = htmlAttributes["style"]; + if (styleString != null) { + final entries = styleString.split(";"); + for (final entry in entries) { + final tuples = entry.split(":"); + if (tuples.length < 2) { + continue; + } + if (tuples[0] == "font-weight") { + int? weight = int.tryParse(tuples[1]); + if (weight != null && weight > 500) { + attrs["bold"] = true; + } + } + } + } + + return attrs.isEmpty ? null : attrs; + } + _handleRichTextElement(Delta delta, html.Element element) { if (element.localName == "span") { - delta.insert(element.text); + delta.insert(element.text, + _getDeltaAttributesFromHtmlAttributes(element.attributes)); } else if (element.localName == "a") { final hyperLink = element.attributes["href"]; Map? attributes; @@ -73,8 +99,10 @@ class HTMLConverter { attributes = {"href": hyperLink}; } delta.insert(element.text, attributes); - } else if (element.localName == "strong") { + } else if (element.localName == "strong" || element.localName == "b") { delta.insert(element.text, {"bold": true}); + } else { + delta.insert(element.text); } } @@ -89,13 +117,7 @@ class HTMLConverter { for (final child in element.nodes.toList()) { if (child is html.Element) { - if (child.localName == "a" || - child.localName == "span" || - child.localName == "strong") { - _handleRichTextElement(delta, element); - } else { - delta.insert(child.text); - } + _handleRichTextElement(delta, child); } else { delta.insert(child.text ?? ""); } @@ -147,3 +169,21 @@ class HTMLConverter { } } } + +String deltaToHtml(Delta delta) { + var result = "

"; + + for (final op in delta.operations) { + if (op is TextInsert) { + final attributes = op.attributes; + if (attributes != null && attributes["bold"] == true) { + result += '${op.content}'; + } else { + result += op.content; + } + } + } + + result += "

"; + return result; +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index 6e6e30dbf4..f413801771 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -5,7 +5,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; -_handleCopy() async { +_handleCopy(EditorState editorState) async { + final selection = editorState.cursorSelection; + if (selection == null || selection.isCollapsed) { + return; + } + if (pathEquals(selection.start.path, selection.end.path)) { + final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!; + if (nodeAtPath.type == "text") { + final textNode = nodeAtPath as TextNode; + final delta = + textNode.delta.slice(selection.start.offset, selection.end.offset); + + final htmlString = deltaToHtml(delta); + debugPrint('copy html: $htmlString'); + RichClipboard.setData(RichClipboardData(html: htmlString)); + } else { + debugPrint("unimplemented: copy non-text"); + } + return; + } debugPrint('copy'); } @@ -20,6 +39,7 @@ _pasteHTML(EditorState editorState, String html) { return; } + debugPrint('paste html: $html'); final converter = HTMLConverter(html); final nodes = converter.toNodes(); @@ -38,6 +58,7 @@ _pasteHTML(EditorState editorState, String html) { tb.setAfterSelection(Selection.collapsed(Position( path: path, offset: startOffset + firstTextNode.delta.length))); tb.commit(); + return; } } @@ -64,12 +85,16 @@ _pasteMultipleLinesInText( .delete(remain.length) .concat(firstTextNode.delta)); - path[path.length - 1]++; final tailNodes = nodes.sublist(1); - if (tailNodes.last.type == "text") { - final tailTextNode = tailNodes.last as TextNode; - tailTextNode.delta = tailTextNode.delta.concat(remain); - } else if (remain.length > 0) { + path[path.length - 1]++; + if (tailNodes.isNotEmpty) { + if (tailNodes.last.type == "text") { + final tailTextNode = tailNodes.last as TextNode; + tailTextNode.delta = tailTextNode.delta.concat(remain); + } else if (remain.length > 0) { + tailNodes.add(TextNode(type: "text", delta: remain)); + } + } else { tailNodes.add(TextNode(type: "text", delta: remain)); } @@ -165,7 +190,7 @@ _handleCut() { FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { - _handleCopy(); + _handleCopy(editorState); return KeyEventResult.handled; } if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 59632773e5..a304272846 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -437,7 +437,7 @@ class _FlowySelectionState extends State final selection = Selection( start: isDownward ? start : end, end: isDownward ? end : start); debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); - editorState.service.selectionService.updateSelection(selection); + editorState.updateCursorSelection(selection); } } From 8da6faa74bc36064ea1511ebd99b1162f4ceffe8 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 19:35:37 +0800 Subject: [PATCH 11/16] feat: node traverser --- .../lib/document/node_traverser.dart | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart new file mode 100644 index 0000000000..b4005f054a --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart @@ -0,0 +1,42 @@ +import 'package:flowy_editor/document/node.dart'; + +import './state_tree.dart'; +import './node.dart'; + +/// [NodeTraverser] is used to traverse the nodes in visual order. +class NodeTraverser { + final StateTree stateTree; + Node? currentNode; + + NodeTraverser(this.stateTree, Node beginNode) : currentNode = beginNode; + + Node? next() { + final node = currentNode; + if (node == null) { + return null; + } + + if (node.children.isNotEmpty) { + currentNode = _findLeadingChild(node); + } else if (node.next != null) { + currentNode = node.next!; + } else { + final parent = node.parent!; + final nextOfParent = parent.next; + if (nextOfParent == null) { + currentNode = null; + } else { + currentNode = _findLeadingChild(node); + } + } + + return node; + } + + Node _findLeadingChild(Node node) { + while (node.children.isNotEmpty) { + node = node.children.first; + } + return node; + } +} From 7ef053eb0d6477ef9c9e1de1fe1c4bd92e817370 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 3 Aug 2022 19:46:13 +0800 Subject: [PATCH 12/16] feat: copy multiple text --- .../copy_paste_handler.dart | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index f413801771..a79c121684 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,6 +1,7 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/infra/html_converter.dart'; +import 'package:flowy_editor/document/node_traverser.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; @@ -25,7 +26,37 @@ _handleCopy(EditorState editorState) async { } return; } - debugPrint('copy'); + + final beginNode = editorState.document.nodeAtPath(selection.start.path)!; + final endNode = editorState.document.nodeAtPath(selection.end.path)!; + final traverser = NodeTraverser(editorState.document, beginNode); + + var copyString = ""; + while (traverser.currentNode != null) { + final node = traverser.next()!; + if (node.type == "text") { + final textNode = node as TextNode; + if (node == beginNode) { + final htmlString = + deltaToHtml(textNode.delta.slice(selection.start.offset)); + copyString += htmlString; + } else if (node == endNode) { + final htmlString = + deltaToHtml(textNode.delta.slice(0, selection.end.offset)); + copyString += htmlString; + } else { + final htmlString = deltaToHtml(textNode.delta); + copyString += htmlString; + } + } + // TODO: handle image and other blocks + + if (node == endNode) { + break; + } + } + debugPrint('copy html: $copyString'); + RichClipboard.setData(RichClipboardData(html: copyString)); } _pasteHTML(EditorState editorState, String html) { From 061168bd8278fab18aee3b7fb63edc39e5976653 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 4 Aug 2022 10:41:07 +0800 Subject: [PATCH 13/16] refactor: use contant variable --- .../lib/infra/html_converter.dart | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart index 6895643744..9c7872c901 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -7,6 +7,18 @@ import 'package:flutter/foundation.dart'; import 'package:html/parser.dart' show parse; import 'package:html/dom.dart' as html; +const String tagH1 = "h1"; +const String tagH2 = "h2"; +const String tagH3 = "h3"; +const String tagUnorderedList = "ul"; +const String tagList = "li"; +const String tagParagraph = "p"; +const String tagImage = "img"; +const String tagAnchor = "a"; +const String tagBold = "b"; +const String tagStrong = "strong"; +const String tagSpan = "span"; + class HTMLConverter { final html.Document _document; @@ -19,10 +31,10 @@ class HTMLConverter { final childNodes = _document.body?.nodes.toList() ?? []; for (final child in childNodes) { if (child is html.Element) { - if (child.localName == "a" || - child.localName == "span" || - child.localName == "strong" || - child.localName == "b") { + if (child.localName == tagAnchor || + child.localName == tagSpan || + child.localName == tagStrong || + child.localName == tagBold) { _handleRichTextElement(delta, child); } else { _handleElement(result, child); @@ -40,17 +52,17 @@ class HTMLConverter { } _handleElement(List nodes, html.Element element) { - if (element.localName == "h1") { - _handleHeadingElement(nodes, element, "h1"); - } else if (element.localName == "h2") { - _handleHeadingElement(nodes, element, "h2"); - } else if (element.localName == "h3") { - _handleHeadingElement(nodes, element, "h3"); - } else if (element.localName == "ul") { + if (element.localName == tagH1) { + _handleHeadingElement(nodes, element, tagH1); + } else if (element.localName == tagH2) { + _handleHeadingElement(nodes, element, tagH2); + } else if (element.localName == tagH3) { + _handleHeadingElement(nodes, element, tagH3); + } else if (element.localName == tagUnorderedList) { _handleUnorderedList(nodes, element); - } else if (element.localName == "li") { + } else if (element.localName == tagList) { _handleListElement(nodes, element); - } else if (element.localName == "p") { + } else if (element.localName == tagParagraph) { _handleParagraph(nodes, element); } else { final delta = Delta(); @@ -89,17 +101,17 @@ class HTMLConverter { } _handleRichTextElement(Delta delta, html.Element element) { - if (element.localName == "span") { + if (element.localName == tagSpan) { delta.insert(element.text, _getDeltaAttributesFromHtmlAttributes(element.attributes)); - } else if (element.localName == "a") { + } else if (element.localName == tagAnchor) { final hyperLink = element.attributes["href"]; Map? attributes; if (hyperLink != null) { attributes = {"href": hyperLink}; } delta.insert(element.text, attributes); - } else if (element.localName == "strong" || element.localName == "b") { + } else if (element.localName == tagStrong || element.localName == tagBold) { delta.insert(element.text, {"bold": true}); } else { delta.insert(element.text); @@ -107,7 +119,7 @@ class HTMLConverter { } _handleRichText(List nodes, html.Element element) { - final image = element.querySelector("img"); + final image = element.querySelector(tagImage); if (image != null) { _handleImage(nodes, image); return; From 2eecda0483e2de585d90a0721a41d12da9f91ef7 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 4 Aug 2022 10:53:41 +0800 Subject: [PATCH 14/16] refactor: from node traverser to node iterator --- .../lib/document/node_iterator.dart | 64 +++++++++++++++++++ .../lib/document/node_traverser.dart | 42 ------------ .../copy_paste_handler.dart | 11 ++-- 3 files changed, 68 insertions(+), 49 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart delete mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart new file mode 100644 index 0000000000..8603c043e4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart @@ -0,0 +1,64 @@ +import 'package:flowy_editor/document/node.dart'; + +import './state_tree.dart'; +import './node.dart'; + +/// [NodeIterator] is used to traverse the nodes in visual order. +class NodeIterator implements Iterator { + final StateTree stateTree; + final Node _startNode; + final Node? _endNode; + Node? _currentNode; + bool _began = false; + + NodeIterator(this.stateTree, Node startNode, [Node? endNode]) + : _startNode = startNode, + _endNode = endNode; + + @override + bool moveNext() { + if (!_began) { + _currentNode = _startNode; + _began = true; + return true; + } + + final node = _currentNode; + if (node == null) { + return false; + } + + if (_endNode != null && _endNode == node) { + _currentNode = null; + return false; + } + + if (node.children.isNotEmpty) { + _currentNode = _findLeadingChild(node); + } else if (node.next != null) { + _currentNode = node.next!; + } else { + final parent = node.parent!; + final nextOfParent = parent.next; + if (nextOfParent == null) { + _currentNode = null; + } else { + _currentNode = _findLeadingChild(node); + } + } + + return _currentNode != null; + } + + Node _findLeadingChild(Node node) { + while (node.children.isNotEmpty) { + node = node.children.first; + } + return node; + } + + @override + Node get current { + return _currentNode!; + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart deleted file mode 100644 index b4005f054a..0000000000 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node_traverser.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flowy_editor/document/node.dart'; - -import './state_tree.dart'; -import './node.dart'; - -/// [NodeTraverser] is used to traverse the nodes in visual order. -class NodeTraverser { - final StateTree stateTree; - Node? currentNode; - - NodeTraverser(this.stateTree, Node beginNode) : currentNode = beginNode; - - Node? next() { - final node = currentNode; - if (node == null) { - return null; - } - - if (node.children.isNotEmpty) { - currentNode = _findLeadingChild(node); - } else if (node.next != null) { - currentNode = node.next!; - } else { - final parent = node.parent!; - final nextOfParent = parent.next; - if (nextOfParent == null) { - currentNode = null; - } else { - currentNode = _findLeadingChild(node); - } - } - - return node; - } - - Node _findLeadingChild(Node node) { - while (node.children.isNotEmpty) { - node = node.children.first; - } - return node; - } -} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart index a79c121684..cde1c4e122 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,7 +1,7 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/infra/html_converter.dart'; -import 'package:flowy_editor/document/node_traverser.dart'; +import 'package:flowy_editor/document/node_iterator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; @@ -29,11 +29,11 @@ _handleCopy(EditorState editorState) async { final beginNode = editorState.document.nodeAtPath(selection.start.path)!; final endNode = editorState.document.nodeAtPath(selection.end.path)!; - final traverser = NodeTraverser(editorState.document, beginNode); + final traverser = NodeIterator(editorState.document, beginNode, endNode); var copyString = ""; - while (traverser.currentNode != null) { - final node = traverser.next()!; + while (traverser.moveNext()) { + final node = traverser.current; if (node.type == "text") { final textNode = node as TextNode; if (node == beginNode) { @@ -51,9 +51,6 @@ _handleCopy(EditorState editorState) async { } // TODO: handle image and other blocks - if (node == endNode) { - break; - } } debugPrint('copy html: $copyString'); RichClipboard.setData(RichClipboardData(html: copyString)); From 8b60cd7abdf2f055c1e59bafc3cf375baffab1f6 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 4 Aug 2022 11:22:38 +0800 Subject: [PATCH 15/16] refactor: use node iterator for selection --- .../lib/document/node_iterator.dart | 10 ++++++++ .../lib/service/selection_service.dart | 23 ++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart index 8603c043e4..1f321e937a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart @@ -61,4 +61,14 @@ class NodeIterator implements Iterator { Node get current { return _currentNode!; } + + List toList() { + final result = []; + + while (moveNext()) { + result.add(current); + } + + return result; + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index a304272846..55b08f9279 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flowy_editor/document/node_iterator.dart'; +import 'package:flowy_editor/document/state_tree.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -212,7 +214,7 @@ class _FlowySelectionState extends State @override List getNodesInSelection(Selection selection) => - _selectedNodesInSelection(editorState.document.root, selection); + _selectedNodesInSelection(editorState.document, selection); @override void initState() { @@ -462,8 +464,7 @@ class _FlowySelectionState extends State } void _updateSelection(Selection selection) { - final nodes = - _selectedNodesInSelection(editorState.document.root, selection); + final nodes = _selectedNodesInSelection(editorState.document, selection); currentSelection = selection; currentSelectedNodes.value = nodes; @@ -572,16 +573,10 @@ class _FlowySelectionState extends State currentState?.show(); } - List _selectedNodesInSelection(Node node, Selection selection) { - List result = []; - if (node.parent != null) { - if (node.inSelection(selection)) { - result.add(node); - } - } - for (final child in node.children) { - result.addAll(_selectedNodesInSelection(child, selection)); - } - return result; + List _selectedNodesInSelection( + StateTree stateTree, Selection selection) { + final startNode = stateTree.nodeAtPath(selection.start.path)!; + final endNode = stateTree.nodeAtPath(selection.end.path)!; + return NodeIterator(stateTree, startNode, endNode).toList(); } } From 90f69c3f2dfdca2f755c5bdfe423aca2ed154625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E5=B1=B9=E5=B3=B0?= <> Date: Thu, 4 Aug 2022 19:43:44 +0800 Subject: [PATCH 16/16] test: add grid URL unit tests --- .../type_options/url_type_option/url_tests.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs index ba221853bb..3cf5d6f99b 100644 --- a/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs +++ b/frontend/rust-lib/flowy-grid/src/services/field/type_options/url_type_option/url_tests.rs @@ -14,6 +14,7 @@ mod tests { let field_type = FieldType::URL; let field_rev = FieldBuilder::from_field_type(&field_type).build(); assert_url(&type_option, "123", "123", "", &field_type, &field_rev); + assert_url(&type_option, "", "", "", &field_type, &field_rev); } /// The expected_str will equal to the input string, but the expected_url will not be empty @@ -42,6 +43,124 @@ mod tests { ); } + /// if there's a http url and some words following it in the input string. + #[test] + fn url_type_option_contains_url_with_string_after_test() { + let type_option = URLTypeOption::default(); + let field_type = FieldType::URL; + let field_rev = FieldBuilder::from_field_type(&field_type).build(); + assert_url( + &type_option, + "AppFlowy website - https://www.appflowy.io welcome!", + "AppFlowy website - https://www.appflowy.io welcome!", + "https://www.appflowy.io/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "AppFlowy website appflowy.io welcome!", + "AppFlowy website appflowy.io welcome!", + "https://appflowy.io", + &field_type, + &field_rev, + ); + } + + /// if there's a http url and special words following it in the input string. + #[test] + fn url_type_option_contains_url_with_special_string_after_test() { + let type_option = URLTypeOption::default(); + let field_type = FieldType::URL; + let field_rev = FieldBuilder::from_field_type(&field_type).build(); + assert_url( + &type_option, + "AppFlowy website - https://www.appflowy.io!", + "AppFlowy website - https://www.appflowy.io!", + "https://www.appflowy.io/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "AppFlowy website appflowy.io!", + "AppFlowy website appflowy.io!", + "https://appflowy.io", + &field_type, + &field_rev, + ); + } + + /// if there's a level4 url in the input string. + #[test] + fn level4_url_type_test() { + let type_option = URLTypeOption::default(); + let field_type = FieldType::URL; + let field_rev = FieldBuilder::from_field_type(&field_type).build(); + assert_url( + &type_option, + "test - https://tester.testgroup.appflowy.io", + "test - https://tester.testgroup.appflowy.io", + "https://tester.testgroup.appflowy.io/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "test tester.testgroup.appflowy.io", + "test tester.testgroup.appflowy.io", + "https://tester.testgroup.appflowy.io", + &field_type, + &field_rev, + ); + } + + /// urls with different top level domains. + #[test] + fn different_top_level_domains_test() { + let type_option = URLTypeOption::default(); + let field_type = FieldType::URL; + let field_rev = FieldBuilder::from_field_type(&field_type).build(); + assert_url( + &type_option, + "appflowy - https://appflowy.com", + "appflowy - https://appflowy.com", + "https://appflowy.com/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "appflowy - https://appflowy.top", + "appflowy - https://appflowy.top", + "https://appflowy.top/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "appflowy - https://appflowy.net", + "appflowy - https://appflowy.net", + "https://appflowy.net/", + &field_type, + &field_rev, + ); + + assert_url( + &type_option, + "appflowy - https://appflowy.edu", + "appflowy - https://appflowy.edu", + "https://appflowy.edu/", + &field_type, + &field_rev, + ); + } + fn assert_url( type_option: &URLTypeOption, input_str: &str,