Merge remote-tracking branch 'origin/main' into feat/toolbar_service

This commit is contained in:
Lucas.Xu 2022-08-05 11:04:21 +08:00
commit 70853b918e
15 changed files with 748 additions and 27 deletions

View File

@ -90,8 +90,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
@override @override
Position getPositionInOffset(Offset start) { Position getPositionInOffset(Offset start) {
// TODO: implement getPositionInOffset return Position(path: node.path, offset: 0);
throw UnimplementedError();
} }
@override @override

View File

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <rich_clipboard_linux/rich_clipboard_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
rich_clipboard_linux
url_launcher_linux url_launcher_linux
) )

View File

@ -5,8 +5,10 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import rich_clipboard_macos
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
RichClipboardPlugin.register(with: registry.registrar(forPlugin: "RichClipboardPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@ -1,20 +1,26 @@
PODS: PODS:
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- rich_clipboard_macos (0.0.1):
- FlutterMacOS
- url_launcher_macos (0.0.1): - url_launcher_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
DEPENDENCIES: DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`) - 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`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES: EXTERNAL SOURCES:
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
rich_clipboard_macos:
:path: Flutter/ephemeral/.symlinks/plugins/rich_clipboard_macos/macos
url_launcher_macos: url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424
rich_clipboard_macos: 43364b66b9dc69d203eb8dd6d758e2d12e02723c
url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3 url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c

View File

@ -43,6 +43,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
csslib:
dependency: transitive
description:
name: csslib
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.2"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -57,6 +64,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
flowy_editor: flowy_editor:
dependency: "direct main" dependency: "direct main"
description: description:
@ -100,6 +114,13 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
html:
dependency: transitive
description:
name: html
url: "https://pub.dartlang.org"
source: hosted
version: "0.15.0"
js: js:
dependency: transitive dependency: transitive
description: description:
@ -184,6 +205,62 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "6.0.3" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -294,6 +371,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
win32:
dependency: transitive
description:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.6.1"
xml: xml:
dependency: transitive dependency: transitive
description: description:
@ -303,4 +387,4 @@ packages:
version: "6.1.0" version: "6.1.0"
sdks: sdks:
dart: ">=2.17.0 <3.0.0" dart: ">=2.17.0 <3.0.0"
flutter: ">=2.11.0-0.1.pre" flutter: ">=3.0.0"

View File

@ -176,10 +176,11 @@ class TextNode extends Node {
TextNode({ TextNode({
required super.type, required super.type,
required super.children,
required super.attributes,
required Delta delta, required Delta delta,
}) : _delta = delta; LinkedList<Node>? children,
Attributes? attributes,
}) : _delta = delta,
super(children: children ?? LinkedList(), attributes: attributes ?? {});
TextNode.empty() TextNode.empty()
: _delta = Delta([TextInsert(' ')]), : _delta = Delta([TextInsert(' ')]),

View File

@ -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<Node> {
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<Node> toList() {
final result = <Node>[];
while (moveNext()) {
result.add(current);
}
return result;
}
}

View File

@ -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<Node> toNodes() {
final result = <Node>[];
final delta = Delta();
final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
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<Node> 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<Node> nodes, html.Element element) {
_handleRichText(nodes, element);
}
Attributes? _getDeltaAttributesFromHtmlAttributes(
LinkedHashMap<Object, String> htmlAttributes) {
final attrs = <String, dynamic>{};
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<String, dynamic>? 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<Node> 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<Node> nodes, html.Element element) {
final src = element.attributes["src"];
final attributes = <String, dynamic>{};
if (src != null) {
attributes["image_src"] = src;
}
debugPrint("insert image: $src");
nodes.add(
Node(type: "image", attributes: attributes, children: LinkedList()));
}
_handleUnorderedList(List<Node> nodes, html.Element element) {
element.children.forEach((child) {
_handleListElement(nodes, child);
});
}
_handleHeadingElement(
List<Node> 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<Node> 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 = "<p>";
for (final op in delta.operations) {
if (op is TextInsert) {
final attributes = op.attributes;
if (attributes != null && attributes["bold"] == true) {
result += '<strong>${op.content}</strong>';
} else {
result += op.content;
}
}
}
result += "</p>";
return result;
}

View File

@ -80,6 +80,10 @@ class TransactionBuilder {
add(TextEditOperation(path, delta, inverted)); add(TextEditOperation(path, delta, inverted));
} }
setAfterSelection(Selection sel) {
afterSelection = sel;
}
mergeText(TextNode firstNode, TextNode secondNode, mergeText(TextNode firstNode, TextNode secondNode,
{int? firstOffset, int secondOffset = 0}) { {int? firstOffset, int secondOffset = 0}) {
final firstLength = firstNode.delta.length; final firstLength = firstNode.delta.length;

View File

@ -1,4 +1,3 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flowy_editor/editor_state.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/render/rich_text/quoted_text.dart';
import 'package:flowy_editor/service/input_service.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/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_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/delete_text_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/enter_in_edge_of_text_node_handler.dart';
@ -37,6 +37,7 @@ List<FlowyKeyEventHandler> defaultKeyEventHandler = [
slashShortcutHandler, slashShortcutHandler,
flowyDeleteNodesHandler, flowyDeleteNodesHandler,
arrowKeysHandler, arrowKeysHandler,
copyPasteKeysHandler,
enterInEdgeOfTextNodeHandler, enterInEdgeOfTextNodeHandler,
updateTextStyleByCommandXHandler, updateTextStyleByCommandXHandler,
]; ];
@ -70,7 +71,6 @@ class _FlowyEditorState extends State<FlowyEditor> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController()..addListener(_scrollCallback);
editorState.service.renderPluginService = _createRenderPlugin(); editorState.service.renderPluginService = _createRenderPlugin();
} }
@ -131,8 +131,4 @@ class _FlowyEditorState extends State<FlowyEditor> {
...widget.customBuilders, ...widget.customBuilders,
}, },
); );
void _scrollCallback() {
debugPrint('scrolling');
}
} }

View File

@ -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<int> path, int offset, List<Node> 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;
};

View File

@ -5,8 +5,10 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flowy_editor/document/node.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/position.dart';
import 'package:flowy_editor/document/selection.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/editor_state.dart';
import 'package:flowy_editor/extensions/node_extensions.dart'; import 'package:flowy_editor/extensions/node_extensions.dart';
import 'package:flowy_editor/render/selection/cursor_widget.dart'; import 'package:flowy_editor/render/selection/cursor_widget.dart';
@ -129,7 +131,7 @@ class _FlowySelectionState extends State<FlowySelection>
@override @override
List<Node> getNodesInSelection(Selection selection) => List<Node> getNodesInSelection(Selection selection) =>
_selectedNodesInSelection(editorState.document.root, selection); _selectedNodesInSelection(editorState.document, selection);
@override @override
void initState() { void initState() {
@ -381,7 +383,7 @@ class _FlowySelectionState extends State<FlowySelection>
final selection = Selection( final selection = Selection(
start: isDownward ? start : end, end: isDownward ? end : start); start: isDownward ? start : end, end: isDownward ? end : start);
debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection'); debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
editorState.service.selectionService.updateSelection(selection); editorState.updateCursorSelection(selection);
} }
_scrollUpOrDownIfNeeded(panEndOffset!); _scrollUpOrDownIfNeeded(panEndOffset!);
@ -393,8 +395,7 @@ class _FlowySelectionState extends State<FlowySelection>
} }
void _updateSelection(Selection selection) { void _updateSelection(Selection selection) {
final nodes = final nodes = _selectedNodesInSelection(editorState.document, selection);
_selectedNodesInSelection(editorState.document.root, selection);
currentSelection = selection; currentSelection = selection;
currentSelectedNodes.value = nodes; currentSelectedNodes.value = nodes;
@ -503,17 +504,11 @@ class _FlowySelectionState extends State<FlowySelection>
currentState?.show(); currentState?.show();
} }
List<Node> _selectedNodesInSelection(Node node, Selection selection) { List<Node> _selectedNodesInSelection(
List<Node> result = []; StateTree stateTree, Selection selection) {
if (node.parent != null) { final startNode = stateTree.nodeAtPath(selection.start.path)!;
if (node.inSelection(selection)) { final endNode = stateTree.nodeAtPath(selection.end.path)!;
result.add(node); return NodeIterator(stateTree, startNode, endNode).toList();
}
}
for (final child in node.children) {
result.addAll(_selectedNodesInSelection(child, selection));
}
return result;
} }
void _scrollUpOrDownIfNeeded(Offset offset) { void _scrollUpOrDownIfNeeded(Offset offset) {

View File

@ -11,6 +11,8 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
rich_clipboard: ^1.0.0
html: ^0.15.0
flutter_svg: ^1.1.1+1 flutter_svg: ^1.1.1+1
provider: ^6.0.3 provider: ^6.0.3

View File

@ -14,6 +14,7 @@ mod tests {
let field_type = FieldType::URL; let field_type = FieldType::URL;
let field_rev = FieldBuilder::from_field_type(&field_type).build(); let field_rev = FieldBuilder::from_field_type(&field_type).build();
assert_url(&type_option, "123", "123", "", &field_type, &field_rev); 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 /// 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( fn assert_url(
type_option: &URLTypeOption, type_option: &URLTypeOption,
input_str: &str, input_str: &str,