mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #761 from AppFlowy-IO/feat/copy-as-html
Feat: copy html from document
This commit is contained in:
commit
22976a6847
@ -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<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!;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,24 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flowy_editor/document/attributes.dart';
|
||||||
import 'package:flowy_editor/document/node.dart';
|
import 'package:flowy_editor/document/node.dart';
|
||||||
import 'package:flowy_editor/document/text_delta.dart';
|
import 'package:flowy_editor/document/text_delta.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:html/parser.dart' show parse;
|
import 'package:html/parser.dart' show parse;
|
||||||
import 'package:html/dom.dart' as html;
|
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 {
|
class HTMLConverter {
|
||||||
final html.Document _document;
|
final html.Document _document;
|
||||||
|
|
||||||
@ -18,9 +31,10 @@ class HTMLConverter {
|
|||||||
final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
|
final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
|
||||||
for (final child in childNodes) {
|
for (final child in childNodes) {
|
||||||
if (child is html.Element) {
|
if (child is html.Element) {
|
||||||
if (child.localName == "a" ||
|
if (child.localName == tagAnchor ||
|
||||||
child.localName == "span" ||
|
child.localName == tagSpan ||
|
||||||
child.localName == "strong") {
|
child.localName == tagStrong ||
|
||||||
|
child.localName == tagBold) {
|
||||||
_handleRichTextElement(delta, child);
|
_handleRichTextElement(delta, child);
|
||||||
} else {
|
} else {
|
||||||
_handleElement(result, child);
|
_handleElement(result, child);
|
||||||
@ -38,17 +52,17 @@ class HTMLConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_handleElement(List<Node> nodes, html.Element element) {
|
_handleElement(List<Node> nodes, html.Element element) {
|
||||||
if (element.localName == "h1") {
|
if (element.localName == tagH1) {
|
||||||
_handleHeadingElement(nodes, element, "h1");
|
_handleHeadingElement(nodes, element, tagH1);
|
||||||
} else if (element.localName == "h2") {
|
} else if (element.localName == tagH2) {
|
||||||
_handleHeadingElement(nodes, element, "h2");
|
_handleHeadingElement(nodes, element, tagH2);
|
||||||
} else if (element.localName == "h3") {
|
} else if (element.localName == tagH3) {
|
||||||
_handleHeadingElement(nodes, element, "h3");
|
_handleHeadingElement(nodes, element, tagH3);
|
||||||
} else if (element.localName == "ul") {
|
} else if (element.localName == tagUnorderedList) {
|
||||||
_handleUnorderedList(nodes, element);
|
_handleUnorderedList(nodes, element);
|
||||||
} else if (element.localName == "li") {
|
} else if (element.localName == tagList) {
|
||||||
_handleListElement(nodes, element);
|
_handleListElement(nodes, element);
|
||||||
} else if (element.localName == "p") {
|
} else if (element.localName == tagParagraph) {
|
||||||
_handleParagraph(nodes, element);
|
_handleParagraph(nodes, element);
|
||||||
} else {
|
} else {
|
||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
@ -63,23 +77,49 @@ class HTMLConverter {
|
|||||||
_handleRichText(nodes, 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) {
|
_handleRichTextElement(Delta delta, html.Element element) {
|
||||||
if (element.localName == "span") {
|
if (element.localName == tagSpan) {
|
||||||
delta.insert(element.text);
|
delta.insert(element.text,
|
||||||
} else if (element.localName == "a") {
|
_getDeltaAttributesFromHtmlAttributes(element.attributes));
|
||||||
|
} else if (element.localName == tagAnchor) {
|
||||||
final hyperLink = element.attributes["href"];
|
final hyperLink = element.attributes["href"];
|
||||||
Map<String, dynamic>? attributes;
|
Map<String, dynamic>? attributes;
|
||||||
if (hyperLink != null) {
|
if (hyperLink != null) {
|
||||||
attributes = {"href": hyperLink};
|
attributes = {"href": hyperLink};
|
||||||
}
|
}
|
||||||
delta.insert(element.text, attributes);
|
delta.insert(element.text, attributes);
|
||||||
} else if (element.localName == "strong") {
|
} else if (element.localName == tagStrong || element.localName == tagBold) {
|
||||||
delta.insert(element.text, {"bold": true});
|
delta.insert(element.text, {"bold": true});
|
||||||
|
} else {
|
||||||
|
delta.insert(element.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleRichText(List<Node> nodes, html.Element element) {
|
_handleRichText(List<Node> nodes, html.Element element) {
|
||||||
final image = element.querySelector("img");
|
final image = element.querySelector(tagImage);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_handleImage(nodes, image);
|
_handleImage(nodes, image);
|
||||||
return;
|
return;
|
||||||
@ -89,13 +129,7 @@ class HTMLConverter {
|
|||||||
|
|
||||||
for (final child in element.nodes.toList()) {
|
for (final child in element.nodes.toList()) {
|
||||||
if (child is html.Element) {
|
if (child is html.Element) {
|
||||||
if (child.localName == "a" ||
|
_handleRichTextElement(delta, child);
|
||||||
child.localName == "span" ||
|
|
||||||
child.localName == "strong") {
|
|
||||||
_handleRichTextElement(delta, element);
|
|
||||||
} else {
|
|
||||||
delta.insert(child.text);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
delta.insert(child.text ?? "");
|
delta.insert(child.text ?? "");
|
||||||
}
|
}
|
||||||
@ -147,3 +181,21 @@ class HTMLConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
@ -1,12 +1,59 @@
|
|||||||
import 'package:flowy_editor/flowy_editor.dart';
|
import 'package:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flowy_editor/service/keyboard_service.dart';
|
import 'package:flowy_editor/service/keyboard_service.dart';
|
||||||
import 'package:flowy_editor/infra/html_converter.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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:rich_clipboard/rich_clipboard.dart';
|
import 'package:rich_clipboard/rich_clipboard.dart';
|
||||||
|
|
||||||
_handleCopy() async {
|
_handleCopy(EditorState editorState) async {
|
||||||
debugPrint('copy');
|
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) {
|
_pasteHTML(EditorState editorState, String html) {
|
||||||
@ -20,6 +67,7 @@ _pasteHTML(EditorState editorState, String html) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint('paste html: $html');
|
||||||
final converter = HTMLConverter(html);
|
final converter = HTMLConverter(html);
|
||||||
final nodes = converter.toNodes();
|
final nodes = converter.toNodes();
|
||||||
|
|
||||||
@ -38,6 +86,7 @@ _pasteHTML(EditorState editorState, String html) {
|
|||||||
tb.setAfterSelection(Selection.collapsed(Position(
|
tb.setAfterSelection(Selection.collapsed(Position(
|
||||||
path: path, offset: startOffset + firstTextNode.delta.length)));
|
path: path, offset: startOffset + firstTextNode.delta.length)));
|
||||||
tb.commit();
|
tb.commit();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,14 +113,18 @@ _pasteMultipleLinesInText(
|
|||||||
.delete(remain.length)
|
.delete(remain.length)
|
||||||
.concat(firstTextNode.delta));
|
.concat(firstTextNode.delta));
|
||||||
|
|
||||||
path[path.length - 1]++;
|
|
||||||
final tailNodes = nodes.sublist(1);
|
final tailNodes = nodes.sublist(1);
|
||||||
|
path[path.length - 1]++;
|
||||||
|
if (tailNodes.isNotEmpty) {
|
||||||
if (tailNodes.last.type == "text") {
|
if (tailNodes.last.type == "text") {
|
||||||
final tailTextNode = tailNodes.last as TextNode;
|
final tailTextNode = tailNodes.last as TextNode;
|
||||||
tailTextNode.delta = tailTextNode.delta.concat(remain);
|
tailTextNode.delta = tailTextNode.delta.concat(remain);
|
||||||
} else if (remain.length > 0) {
|
} else if (remain.length > 0) {
|
||||||
tailNodes.add(TextNode(type: "text", delta: remain));
|
tailNodes.add(TextNode(type: "text", delta: remain));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
tailNodes.add(TextNode(type: "text", delta: remain));
|
||||||
|
}
|
||||||
|
|
||||||
tb.insertNodes(path, tailNodes);
|
tb.insertNodes(path, tailNodes);
|
||||||
tb.commit();
|
tb.commit();
|
||||||
@ -165,7 +218,7 @@ _handleCut() {
|
|||||||
|
|
||||||
FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) {
|
FlowyKeyEventHandler copyPasteKeysHandler = (editorState, event) {
|
||||||
if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) {
|
if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyC) {
|
||||||
_handleCopy();
|
_handleCopy(editorState);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) {
|
if (event.isMetaPressed && event.logicalKey == LogicalKeyboardKey.keyV) {
|
||||||
|
@ -437,7 +437,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user