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/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/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 new file mode 100644 index 0000000000..ece4f6b9f4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -0,0 +1,149 @@ +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; + +class HTMLConverter { + final html.Document _document; + + HTMLConverter(String htmlString) : _document = parse(htmlString); + + List toNodes() { + final result = []; + final delta = Delta(); + + 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") { + _handleRichTextElement(delta, child); + } else { + _handleElement(result, child); + } + } else { + delta.insert(child.text ?? ""); + } + } + + if (delta.operations.isNotEmpty) { + result.add(TextNode(type: "text", delta: delta)); + } + + 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) { + _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); + return; + } + + var delta = Delta(); + + 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); + } + } else { + delta.insert(child.text ?? ""); + } + } + + if (delta.operations.isNotEmpty) { + nodes.add(TextNode(type: "text", delta: delta)); + } + } + + _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())); + } + + _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 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/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/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..6e6e30dbf4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -0,0 +1,180 @@ +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 selection = editorState.cursorSelection; + if (selection == null) { + return; + } + + final path = [...selection.end.path]; + if (path.isEmpty) { + return; + } + + 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(); + } + } + + _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; + } + + path[path.length - 1]++; + tb.insertNodes(path, nodes); + tb.commit(); +} + +_handlePaste(EditorState editorState) async { + final data = await RichClipboard.getData(); + if (data.html != null) { + _pasteHTML(editorState, data.html!); + return; + } + if (data.text != null) { + _handlePastePlainText(editorState, data.text!); + return; + } +} + +_handlePastePlainText(EditorState editorState, String plainText) { + final selection = editorState.cursorSelection; + if (selection == null) { + return; + } + + final lines = plainText + .split("\n") + .map((e) => e.replaceAll(RegExp(r'\r'), "")) + .toList(); + + 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(); + + // fixme: don't set the cursor manually + editorState.updateCursorSelection(Selection.collapsed( + Position(path: nodes.last.path, offset: lines.last.length))); + } +} + +_handleCut() { + debugPrint('cut'); +} + +FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) { + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) { + _handleCopy(); + return KeyEventResult.handled; + } + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) { + _handlePaste(editorState); + return KeyEventResult.handled; + } + if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyX) { + _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