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 83334af630..a7eb6e1446 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: @@ -100,6 +114,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: @@ -184,6 +205,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 @@ -294,6 +371,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: @@ -303,4 +387,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/document/node_iterator.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart new file mode 100644 index 0000000000..1f321e937a --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node_iterator.dart @@ -0,0 +1,74 @@ +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!; + } + + List toList() { + final result = []; + + while (moveNext()) { + result.add(current); + } + + return result; + } +} 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..9c7872c901 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/infra/html_converter.dart @@ -0,0 +1,201 @@ +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'; +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; + + 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 == tagAnchor || + child.localName == tagSpan || + child.localName == tagStrong || + child.localName == tagBold) { + _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 == 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 == tagList) { + _handleListElement(nodes, element); + } else if (element.localName == tagParagraph) { + _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); + } + + 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 == tagSpan) { + delta.insert(element.text, + _getDeltaAttributesFromHtmlAttributes(element.attributes)); + } 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 == tagStrong || element.localName == tagBold) { + delta.insert(element.text, {"bold": true}); + } else { + delta.insert(element.text); + } + } + + _handleRichText(List nodes, html.Element element) { + final image = element.querySelector(tagImage); + if (image != null) { + _handleImage(nodes, image); + return; + } + + var delta = Delta(); + + for (final child in element.nodes.toList()) { + if (child is html.Element) { + _handleRichTextElement(delta, child); + } 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); + } + } + } +} + +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/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 d68db05adc..d1fb4aac9c 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 @@ -1,4 +1,3 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/editor_state.dart'; @@ -11,6 +10,7 @@ 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/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/delete_text_handler.dart'; import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart'; @@ -37,6 +37,7 @@ List defaultKeyEventHandler = [ slashShortcutHandler, flowyDeleteNodesHandler, arrowKeysHandler, + copyPasteKeysHandler, enterInEdgeOfTextNodeHandler, updateTextStyleByCommandXHandler, ]; @@ -70,7 +71,6 @@ class _FlowyEditorState extends State { void initState() { super.initState(); - _scrollController = ScrollController()..addListener(_scrollCallback); editorState.service.renderPluginService = _createRenderPlugin(); } @@ -131,8 +131,4 @@ class _FlowyEditorState extends State { ...widget.customBuilders, }, ); - - void _scrollCallback() { - debugPrint('scrolling'); - } } 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..cde1c4e122 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/copy_paste_handler.dart @@ -0,0 +1,233 @@ +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_iterator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rich_clipboard/rich_clipboard.dart'; + +_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; + } + + final beginNode = editorState.document.nodeAtPath(selection.start.path)!; + final endNode = editorState.document.nodeAtPath(selection.end.path)!; + final traverser = NodeIterator(editorState.document, beginNode, endNode); + + var copyString = ""; + while (traverser.moveNext()) { + final node = traverser.current; + 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 + + } + debugPrint('copy html: $copyString'); + RichClipboard.setData(RichClipboardData(html: copyString)); +} + +_pasteHTML(EditorState editorState, String html) { + final selection = editorState.cursorSelection; + if (selection == null) { + return; + } + + final path = [...selection.end.path]; + if (path.isEmpty) { + return; + } + + debugPrint('paste html: $html'); + 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; + } + } + + _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)); + + final tailNodes = nodes.sublist(1); + 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)); + } + + 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(editorState); + 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/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index b879ea419a..c54ef90f9f 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 @@ -5,8 +5,10 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/node_iterator.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/document/state_tree.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flowy_editor/extensions/node_extensions.dart'; import 'package:flowy_editor/render/selection/cursor_widget.dart'; @@ -129,7 +131,7 @@ class _FlowySelectionState extends State @override List getNodesInSelection(Selection selection) => - _selectedNodesInSelection(editorState.document.root, selection); + _selectedNodesInSelection(editorState.document, selection); @override void initState() { @@ -381,7 +383,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); } _scrollUpOrDownIfNeeded(panEndOffset!); @@ -393,8 +395,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; @@ -503,17 +504,11 @@ 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(); } void _scrollUpOrDownIfNeeded(Offset offset) { diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index d828d5501e..05c87f8e33 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 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,