mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #803 from AppFlowy-IO/feat/copy-styles
Feat: copy styles
This commit is contained in:
commit
e25b507ccb
@ -73,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"insert": " / ",
|
"insert": " / ",
|
||||||
"attributes": { "highlightColor": "0xFFFFFF00" }
|
"attributes": { "backgroundColor": "0xFFFFFF00" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc."
|
"insert": "to see all the types of content you can add - entity, headers, videos, sub pages, etc."
|
||||||
|
@ -3,7 +3,7 @@ import 'dart:collection';
|
|||||||
import 'package:flowy_editor/src/document/attributes.dart';
|
import 'package:flowy_editor/src/document/attributes.dart';
|
||||||
import 'package:flowy_editor/src/document/node.dart';
|
import 'package:flowy_editor/src/document/node.dart';
|
||||||
import 'package:flowy_editor/src/document/text_delta.dart';
|
import 'package:flowy_editor/src/document/text_delta.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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;
|
||||||
@ -11,6 +11,7 @@ import 'package:html/dom.dart' as html;
|
|||||||
const String tagH1 = "h1";
|
const String tagH1 = "h1";
|
||||||
const String tagH2 = "h2";
|
const String tagH2 = "h2";
|
||||||
const String tagH3 = "h3";
|
const String tagH3 = "h3";
|
||||||
|
const String tagOrderedList = "ol";
|
||||||
const String tagUnorderedList = "ul";
|
const String tagUnorderedList = "ul";
|
||||||
const String tagList = "li";
|
const String tagList = "li";
|
||||||
const String tagParagraph = "p";
|
const String tagParagraph = "p";
|
||||||
@ -21,23 +22,33 @@ const String tagStrong = "strong";
|
|||||||
const String tagSpan = "span";
|
const String tagSpan = "span";
|
||||||
const String tagCode = "code";
|
const String tagCode = "code";
|
||||||
|
|
||||||
class HTMLConverter {
|
extension on Color {
|
||||||
final html.Document _document;
|
String toRgbaString() {
|
||||||
bool _inParagraph = false;
|
return 'rgba($red, $green, $blue, $alpha)';
|
||||||
|
}
|
||||||
HTMLConverter(String htmlString) : _document = parse(htmlString);
|
|
||||||
|
|
||||||
List<Node> toNodes() {
|
|
||||||
final result = <Node>[];
|
|
||||||
|
|
||||||
final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
|
|
||||||
_handleContainer(result, childNodes);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleContainer(List<Node> nodes, List<html.Node> childNodes) {
|
/// Converting the HTML to nodes
|
||||||
|
class HTMLToNodesConverter {
|
||||||
|
final html.Document _document;
|
||||||
|
|
||||||
|
/// This flag is used for parsing HTML pasting from Google Docs
|
||||||
|
/// Google docs wraps the the content inside the `<b></b>` tag. It's strange.
|
||||||
|
///
|
||||||
|
/// If a `<b>` element is parsing in the <p>, we regard it as as text spans.
|
||||||
|
/// Otherwise, it's parsed as a container.
|
||||||
|
bool _inParagraph = false;
|
||||||
|
|
||||||
|
HTMLToNodesConverter(String htmlString) : _document = parse(htmlString);
|
||||||
|
|
||||||
|
List<Node> toNodes() {
|
||||||
|
final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];
|
||||||
|
return _handleContainer(childNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Node> _handleContainer(List<html.Node> childNodes) {
|
||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
|
final result = <Node>[];
|
||||||
for (final child in childNodes) {
|
for (final child in childNodes) {
|
||||||
if (child is html.Element) {
|
if (child is html.Element) {
|
||||||
if (child.localName == tagAnchor ||
|
if (child.localName == tagAnchor ||
|
||||||
@ -46,83 +57,139 @@ class HTMLConverter {
|
|||||||
child.localName == tagStrong) {
|
child.localName == tagStrong) {
|
||||||
_handleRichTextElement(delta, child);
|
_handleRichTextElement(delta, child);
|
||||||
} else if (child.localName == tagBold) {
|
} else if (child.localName == tagBold) {
|
||||||
// Google docs wraps the the content inside the <b></b> tag.
|
// Google docs wraps the the content inside the `<b></b>` tag.
|
||||||
// It's strange
|
// It's strange
|
||||||
if (!_inParagraph) {
|
if (!_inParagraph) {
|
||||||
_handleBTag(nodes, child);
|
result.addAll(_handleBTag(child));
|
||||||
} else {
|
} else {
|
||||||
_handleRichText(nodes, child);
|
result.add(_handleRichText(child));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_handleElement(nodes, child);
|
result.addAll(_handleElement(child));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
delta.insert(child.text ?? "");
|
delta.insert(child.text ?? "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (delta.operations.isNotEmpty) {
|
if (delta.operations.isNotEmpty) {
|
||||||
nodes.add(TextNode(type: "text", delta: delta));
|
result.add(TextNode(type: "text", delta: delta));
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleBTag(List<Node> nodes, html.Element element) {
|
List<Node> _handleBTag(html.Element element) {
|
||||||
final childNodes = element.nodes;
|
final childNodes = element.nodes;
|
||||||
_handleContainer(nodes, childNodes);
|
return _handleContainer(childNodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleElement(List<Node> nodes, html.Element element,
|
List<Node> _handleElement(html.Element element,
|
||||||
[Map<String, dynamic>? attributes]) {
|
[Map<String, dynamic>? attributes]) {
|
||||||
if (element.localName == tagH1) {
|
if (element.localName == tagH1) {
|
||||||
_handleHeadingElement(nodes, element, tagH1);
|
return [_handleHeadingElement(element, tagH1)];
|
||||||
} else if (element.localName == tagH2) {
|
} else if (element.localName == tagH2) {
|
||||||
_handleHeadingElement(nodes, element, tagH2);
|
return [_handleHeadingElement(element, tagH2)];
|
||||||
} else if (element.localName == tagH3) {
|
} else if (element.localName == tagH3) {
|
||||||
_handleHeadingElement(nodes, element, tagH3);
|
return [_handleHeadingElement(element, tagH3)];
|
||||||
} else if (element.localName == tagUnorderedList) {
|
} else if (element.localName == tagUnorderedList) {
|
||||||
_handleUnorderedList(nodes, element);
|
return _handleUnorderedList(element);
|
||||||
|
} else if (element.localName == tagOrderedList) {
|
||||||
|
return _handleOrderedList(element);
|
||||||
} else if (element.localName == tagList) {
|
} else if (element.localName == tagList) {
|
||||||
_handleListElement(nodes, element);
|
return _handleListElement(element);
|
||||||
} else if (element.localName == tagParagraph) {
|
} else if (element.localName == tagParagraph) {
|
||||||
_handleParagraph(nodes, element, attributes);
|
return [_handleParagraph(element, attributes)];
|
||||||
} else {
|
} else {
|
||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
if (delta.operations.isNotEmpty) {
|
if (delta.operations.isNotEmpty) {
|
||||||
nodes.add(TextNode(type: "text", delta: delta));
|
return [TextNode(type: "text", delta: delta)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleParagraph(List<Node> nodes, html.Element element,
|
Node _handleParagraph(html.Element element,
|
||||||
[Map<String, dynamic>? attributes]) {
|
[Map<String, dynamic>? attributes]) {
|
||||||
_inParagraph = true;
|
_inParagraph = true;
|
||||||
_handleRichText(nodes, element, attributes);
|
final node = _handleRichText(element, attributes);
|
||||||
_inParagraph = false;
|
_inParagraph = false;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _cssStringToMap(String? cssString) {
|
||||||
|
final result = <String, String>{};
|
||||||
|
if (cssString == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final entries = cssString.split(";");
|
||||||
|
for (final entry in entries) {
|
||||||
|
final tuples = entry.split(":");
|
||||||
|
if (tuples.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[tuples[0]] = tuples[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Attributes? _getDeltaAttributesFromHtmlAttributes(
|
Attributes? _getDeltaAttributesFromHtmlAttributes(
|
||||||
LinkedHashMap<Object, String> htmlAttributes) {
|
LinkedHashMap<Object, String> htmlAttributes) {
|
||||||
final attrs = <String, dynamic>{};
|
final attrs = <String, dynamic>{};
|
||||||
final styleString = htmlAttributes["style"];
|
final styleString = htmlAttributes["style"];
|
||||||
if (styleString != null) {
|
final cssMap = _cssStringToMap(styleString);
|
||||||
final entries = styleString.split(";");
|
|
||||||
for (final entry in entries) {
|
final fontWeightStr = cssMap["font-weight"];
|
||||||
final tuples = entry.split(":");
|
if (fontWeightStr != null) {
|
||||||
if (tuples.length < 2) {
|
int? weight = int.tryParse(fontWeightStr);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (tuples[0] == "font-weight") {
|
|
||||||
int? weight = int.tryParse(tuples[1]);
|
|
||||||
if (weight != null && weight > 500) {
|
if (weight != null && weight > 500) {
|
||||||
attrs["bold"] = true;
|
attrs["bold"] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
final backgroundColorStr = cssMap["background-color"];
|
||||||
|
final backgroundColor = _tryParseCssColorString(backgroundColorStr);
|
||||||
|
if (backgroundColor != null) {
|
||||||
|
attrs[StyleKey.backgroundColor] =
|
||||||
|
'0x${backgroundColor.value.toRadixString(16)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
return attrs.isEmpty ? null : attrs;
|
return attrs.isEmpty ? null : attrs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to parse the `rgba(red, greed, blue, alpha)`
|
||||||
|
/// from the string.
|
||||||
|
Color? _tryParseCssColorString(String? colorString) {
|
||||||
|
if (colorString == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final reg = RegExp(r'rgba\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)');
|
||||||
|
final match = reg.firstMatch(colorString);
|
||||||
|
if (match == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.groupCount < 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final redStr = match.group(1);
|
||||||
|
final greenStr = match.group(2);
|
||||||
|
final blueStr = match.group(3);
|
||||||
|
final alphaStr = match.group(4);
|
||||||
|
|
||||||
|
final red = redStr != null ? int.tryParse(redStr) : null;
|
||||||
|
final green = greenStr != null ? int.tryParse(greenStr) : null;
|
||||||
|
final blue = blueStr != null ? int.tryParse(blueStr) : null;
|
||||||
|
final alpha = alphaStr != null ? int.tryParse(alphaStr) : null;
|
||||||
|
|
||||||
|
if (red == null || green == null || blue == null || alpha == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Color.fromARGB(alpha, red, green, blue);
|
||||||
|
}
|
||||||
|
|
||||||
_handleRichTextElement(Delta delta, html.Element element) {
|
_handleRichTextElement(Delta delta, html.Element element) {
|
||||||
if (element.localName == tagSpan) {
|
if (element.localName == tagSpan) {
|
||||||
delta.insert(element.text,
|
delta.insert(element.text,
|
||||||
@ -141,15 +208,27 @@ class HTMLConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleRichText(List<Node> nodes, html.Element element,
|
/// A container contains a <input type="checkbox" > will
|
||||||
|
/// be regarded as a checkbox block.
|
||||||
|
///
|
||||||
|
/// A container contains a <img /> will be regarded as a image block
|
||||||
|
Node _handleRichText(html.Element element,
|
||||||
[Map<String, dynamic>? attributes]) {
|
[Map<String, dynamic>? attributes]) {
|
||||||
final image = element.querySelector(tagImage);
|
final image = element.querySelector(tagImage);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
_handleImage(nodes, image);
|
final imageNode = _handleImage(image);
|
||||||
return;
|
return imageNode;
|
||||||
|
}
|
||||||
|
final testInput = element.querySelector("input");
|
||||||
|
bool checked = false;
|
||||||
|
final isCheckbox =
|
||||||
|
testInput != null && testInput.attributes["type"] == "checkbox";
|
||||||
|
if (isCheckbox) {
|
||||||
|
checked = testInput.attributes.containsKey("checked") &&
|
||||||
|
testInput.attributes["checked"] != "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
var delta = Delta();
|
final delta = Delta();
|
||||||
|
|
||||||
for (final child in element.nodes.toList()) {
|
for (final child in element.nodes.toList()) {
|
||||||
if (child is html.Element) {
|
if (child is html.Element) {
|
||||||
@ -159,68 +238,231 @@ class HTMLConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (delta.operations.isNotEmpty) {
|
final textNode =
|
||||||
nodes.add(TextNode(type: "text", delta: delta, attributes: attributes));
|
TextNode(type: "text", delta: delta, attributes: attributes);
|
||||||
|
if (isCheckbox) {
|
||||||
|
textNode.attributes["subtype"] = StyleKey.checkbox;
|
||||||
|
textNode.attributes["checkbox"] = checked;
|
||||||
}
|
}
|
||||||
|
return textNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleImage(List<Node> nodes, html.Element element) {
|
Node _handleImage(html.Element element) {
|
||||||
final src = element.attributes["src"];
|
final src = element.attributes["src"];
|
||||||
final attributes = <String, dynamic>{};
|
final attributes = <String, dynamic>{};
|
||||||
if (src != null) {
|
if (src != null) {
|
||||||
attributes["image_src"] = src;
|
attributes["image_src"] = src;
|
||||||
}
|
}
|
||||||
debugPrint("insert image: $src");
|
return Node(type: "image", attributes: attributes, children: LinkedList());
|
||||||
nodes.add(
|
|
||||||
Node(type: "image", attributes: attributes, children: LinkedList()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleUnorderedList(List<Node> nodes, html.Element element) {
|
List<Node> _handleUnorderedList(html.Element element) {
|
||||||
|
final result = <Node>[];
|
||||||
element.children.forEach((child) {
|
element.children.forEach((child) {
|
||||||
_handleListElement(nodes, child);
|
result.addAll(
|
||||||
|
_handleListElement(child, {"subtype": StyleKey.bulletedList}));
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleHeadingElement(
|
List<Node> _handleOrderedList(html.Element element) {
|
||||||
List<Node> nodes,
|
final result = <Node>[];
|
||||||
|
for (var i = 0; i < element.children.length; i++) {
|
||||||
|
final child = element.children[i];
|
||||||
|
result.addAll(_handleListElement(
|
||||||
|
child, {"subtype": StyleKey.numberList, "number": i + 1}));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Node _handleHeadingElement(
|
||||||
html.Element element,
|
html.Element element,
|
||||||
String headingStyle,
|
String headingStyle,
|
||||||
) {
|
) {
|
||||||
final delta = Delta();
|
final delta = Delta();
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
if (delta.operations.isNotEmpty) {
|
return TextNode(
|
||||||
nodes.add(TextNode(
|
|
||||||
type: "text",
|
type: "text",
|
||||||
attributes: {"subtype": "heading", "heading": headingStyle},
|
attributes: {"subtype": "heading", "heading": headingStyle},
|
||||||
delta: delta));
|
delta: delta);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleListElement(List<Node> nodes, html.Element element) {
|
List<Node> _handleListElement(html.Element element,
|
||||||
|
[Map<String, dynamic>? attributes]) {
|
||||||
|
final result = <Node>[];
|
||||||
final childNodes = element.nodes.toList();
|
final childNodes = element.nodes.toList();
|
||||||
for (final child in childNodes) {
|
for (final child in childNodes) {
|
||||||
if (child is html.Element) {
|
if (child is html.Element) {
|
||||||
_handleElement(nodes, child, {"subtype": "bulleted-list"});
|
result.addAll(_handleElement(child, attributes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [NodesToHTMLConverter] is used to convert the nodes to HTML.
|
||||||
|
/// Can be used to copy & paste, exporting the document.
|
||||||
|
class NodesToHTMLConverter {
|
||||||
|
final List<Node> nodes;
|
||||||
|
final int? startOffset;
|
||||||
|
final int? endOffset;
|
||||||
|
final List<html.Node> _result = [];
|
||||||
|
|
||||||
|
/// According to the W3C specs. The bullet list should be wrapped as
|
||||||
|
///
|
||||||
|
/// <ul>
|
||||||
|
/// <li>xxx</li>
|
||||||
|
/// <li>xxx</li>
|
||||||
|
/// <li>xxx</li>
|
||||||
|
/// </ul>
|
||||||
|
///
|
||||||
|
/// This container is used to save the list elements temporarily.
|
||||||
|
html.Element? _stashListContainer;
|
||||||
|
|
||||||
|
NodesToHTMLConverter(
|
||||||
|
{required this.nodes, this.startOffset, this.endOffset}) {
|
||||||
|
if (nodes.isEmpty) {
|
||||||
|
return;
|
||||||
|
} else if (nodes.length == 1) {
|
||||||
|
final first = nodes.first;
|
||||||
|
if (first is TextNode) {
|
||||||
|
nodes[0] = first.copyWith(
|
||||||
|
delta: first.delta.slice(startOffset ?? 0, endOffset));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final first = nodes.first;
|
||||||
|
final last = nodes.last;
|
||||||
|
if (first is TextNode) {
|
||||||
|
nodes[0] = first.copyWith(delta: first.delta.slice(startOffset ?? 0));
|
||||||
|
}
|
||||||
|
if (last is TextNode) {
|
||||||
|
nodes[nodes.length - 1] =
|
||||||
|
last.copyWith(delta: last.delta.slice(0, endOffset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html.Element deltaToHtml(Delta delta, [String? subType]) {
|
List<html.Node> toHTMLNodes() {
|
||||||
|
for (final node in nodes) {
|
||||||
|
if (node.type == "text") {
|
||||||
|
final textNode = node as TextNode;
|
||||||
|
if (node == nodes.first) {
|
||||||
|
_addTextNode(textNode);
|
||||||
|
} else if (node == nodes.last) {
|
||||||
|
_addTextNode(textNode, end: endOffset);
|
||||||
|
} else {
|
||||||
|
_addTextNode(textNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: handle image and other blocks
|
||||||
|
}
|
||||||
|
if (_stashListContainer != null) {
|
||||||
|
_result.add(_stashListContainer!);
|
||||||
|
_stashListContainer = null;
|
||||||
|
}
|
||||||
|
return _result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addTextNode(TextNode textNode, {int? end}) {
|
||||||
|
_addElement(textNode, _textNodeToHtml(textNode, end: end));
|
||||||
|
}
|
||||||
|
|
||||||
|
_addElement(TextNode textNode, html.Element element) {
|
||||||
|
if (element.localName == tagList) {
|
||||||
|
final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList;
|
||||||
|
_stashListContainer ??=
|
||||||
|
html.Element.tag(isNumbered ? tagOrderedList : tagUnorderedList);
|
||||||
|
_stashListContainer?.append(element);
|
||||||
|
} else {
|
||||||
|
if (_stashListContainer != null) {
|
||||||
|
_result.add(_stashListContainer!);
|
||||||
|
_stashListContainer = null;
|
||||||
|
}
|
||||||
|
_result.add(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String toHTMLString() {
|
||||||
|
final elements = toHTMLNodes();
|
||||||
|
final copyString = elements.fold<String>(
|
||||||
|
"", ((previousValue, element) => previousValue + stringify(element)));
|
||||||
|
return copyString;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.Element _textNodeToHtml(TextNode textNode, {int? end}) {
|
||||||
|
String? subType = textNode.attributes["subtype"];
|
||||||
|
return _deltaToHtml(textNode.delta,
|
||||||
|
subType: subType,
|
||||||
|
end: end,
|
||||||
|
checked: textNode.attributes["checkbox"] == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _attributesToCssStyle(Map<String, dynamic> attributes) {
|
||||||
|
final cssMap = <String, String>{};
|
||||||
|
if (attributes[StyleKey.backgroundColor] != null) {
|
||||||
|
final color = Color(
|
||||||
|
int.parse(attributes[StyleKey.backgroundColor]),
|
||||||
|
);
|
||||||
|
cssMap["background-color"] = color.toRgbaString();
|
||||||
|
}
|
||||||
|
if (attributes[StyleKey.color] != null) {
|
||||||
|
final color = Color(
|
||||||
|
int.parse(attributes[StyleKey.color]),
|
||||||
|
);
|
||||||
|
cssMap["color"] = color.toRgbaString();
|
||||||
|
}
|
||||||
|
if (attributes[StyleKey.bold] == true) {
|
||||||
|
cssMap["font-weight"] = "bold";
|
||||||
|
}
|
||||||
|
return _cssMapToCssStyle(cssMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cssMapToCssStyle(Map<String, String> cssMap) {
|
||||||
|
return cssMap.entries.fold("", (previousValue, element) {
|
||||||
|
final kv = '${element.key}: ${element.value}';
|
||||||
|
if (previousValue.isEmpty) {
|
||||||
|
return kv;
|
||||||
|
}
|
||||||
|
return '$previousValue; $kv';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html.Element _deltaToHtml(Delta delta,
|
||||||
|
{String? subType, int? end, bool? checked}) {
|
||||||
|
if (end != null) {
|
||||||
|
delta = delta.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
final childNodes = <html.Node>[];
|
final childNodes = <html.Node>[];
|
||||||
String tagName = tagParagraph;
|
String tagName = tagParagraph;
|
||||||
|
|
||||||
if (subType == "bulleted-list") {
|
if (subType == StyleKey.bulletedList || subType == StyleKey.numberList) {
|
||||||
tagName = tagList;
|
tagName = tagList;
|
||||||
|
} else if (subType == StyleKey.checkbox) {
|
||||||
|
final node = html.Element.html('<input type="checkbox" />');
|
||||||
|
if (checked != null && checked) {
|
||||||
|
node.attributes["checked"] = "true";
|
||||||
|
}
|
||||||
|
childNodes.add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final op in delta.operations) {
|
for (final op in delta.operations) {
|
||||||
if (op is TextInsert) {
|
if (op is TextInsert) {
|
||||||
final attributes = op.attributes;
|
final attributes = op.attributes;
|
||||||
if (attributes != null && attributes["bold"] == true) {
|
if (attributes != null) {
|
||||||
final strong = html.Element.tag("strong");
|
if (attributes.length == 1 && attributes[StyleKey.bold] == true) {
|
||||||
|
final strong = html.Element.tag(tagStrong);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.content));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
|
} else {
|
||||||
|
final span = html.Element.tag(tagSpan);
|
||||||
|
final cssString = _attributesToCssStyle(attributes);
|
||||||
|
if (cssString.isNotEmpty) {
|
||||||
|
span.attributes["style"] = cssString;
|
||||||
|
}
|
||||||
|
span.append(html.Text(op.content));
|
||||||
|
childNodes.add(span);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
childNodes.add(html.Text(op.content));
|
childNodes.add(html.Text(op.content));
|
||||||
}
|
}
|
||||||
@ -232,7 +474,7 @@ html.Element deltaToHtml(Delta delta, [String? subType]) {
|
|||||||
for (final node in childNodes) {
|
for (final node in childNodes) {
|
||||||
p.append(node);
|
p.append(node);
|
||||||
}
|
}
|
||||||
final result = html.Element.tag("li");
|
final result = html.Element.tag(tagList);
|
||||||
result.append(p);
|
result.append(p);
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
@ -243,16 +485,11 @@ html.Element deltaToHtml(Delta delta, [String? subType]) {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String stringify(html.Node node) {
|
String stringify(html.Node node) {
|
||||||
if (node is html.Element) {
|
if (node is html.Element) {
|
||||||
String result = '<${node.localName}>';
|
return node.outerHtml;
|
||||||
|
|
||||||
for (final node in node.nodes) {
|
|
||||||
result += stringify(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result += '</${node.localName}>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node is html.Text) {
|
if (node is html.Text) {
|
||||||
|
@ -22,7 +22,7 @@ class StyleKey {
|
|||||||
static String underline = 'underline';
|
static String underline = 'underline';
|
||||||
static String strikethrough = 'strikethrough';
|
static String strikethrough = 'strikethrough';
|
||||||
static String color = 'color';
|
static String color = 'color';
|
||||||
static String highlightColor = 'highlightColor';
|
static String backgroundColor = 'backgroundColor';
|
||||||
static String font = 'font';
|
static String font = 'font';
|
||||||
static String href = 'href';
|
static String href = 'href';
|
||||||
|
|
||||||
@ -151,11 +151,11 @@ extension DeltaAttributesExtensions on Attributes {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color? get highlightColor {
|
Color? get backgroundColor {
|
||||||
if (containsKey(StyleKey.highlightColor) &&
|
if (containsKey(StyleKey.backgroundColor) &&
|
||||||
this[StyleKey.highlightColor] is String) {
|
this[StyleKey.backgroundColor] is String) {
|
||||||
return Color(
|
return Color(
|
||||||
int.parse(this[StyleKey.highlightColor]),
|
int.parse(this[StyleKey.backgroundColor]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -266,8 +266,8 @@ class RichTextStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color? get _backgroundColor {
|
Color? get _backgroundColor {
|
||||||
if (attributes.highlightColor != null) {
|
if (attributes.backgroundColor != null) {
|
||||||
return attributes.highlightColor!;
|
return attributes.backgroundColor!;
|
||||||
} else if (attributes.code) {
|
} else if (attributes.code) {
|
||||||
return Colors.grey.withOpacity(0.4);
|
return Colors.grey.withOpacity(0.4);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import 'package:html/dom.dart' as html;
|
|
||||||
import 'package:flowy_editor/flowy_editor.dart';
|
import 'package:flowy_editor/flowy_editor.dart';
|
||||||
import 'package:flowy_editor/src/service/keyboard_service.dart';
|
import 'package:flowy_editor/src/service/keyboard_service.dart';
|
||||||
import 'package:flowy_editor/src/infra/html_converter.dart';
|
import 'package:flowy_editor/src/infra/html_converter.dart';
|
||||||
@ -7,50 +6,6 @@ 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';
|
||||||
|
|
||||||
class _HTMLNormalizer {
|
|
||||||
final List<html.Node> nodes;
|
|
||||||
html.Element? _pendingList;
|
|
||||||
|
|
||||||
_HTMLNormalizer(this.nodes);
|
|
||||||
|
|
||||||
List<html.Node> normalize() {
|
|
||||||
final result = <html.Node>[];
|
|
||||||
|
|
||||||
for (final item in nodes) {
|
|
||||||
if (item is Text) {
|
|
||||||
result.add(item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item is html.Element) {
|
|
||||||
if (item.localName == "li") {
|
|
||||||
if (_pendingList != null) {
|
|
||||||
_pendingList!.append(item);
|
|
||||||
} else {
|
|
||||||
final ulItem = html.Element.tag("ul");
|
|
||||||
ulItem.append(item);
|
|
||||||
|
|
||||||
_pendingList = ulItem;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_pushList(result);
|
|
||||||
result.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pushList(List<html.Node> result) {
|
|
||||||
if (_pendingList == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
result.add(_pendingList!);
|
|
||||||
_pendingList = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleCopy(EditorState editorState) async {
|
_handleCopy(EditorState editorState) async {
|
||||||
final selection = editorState.cursorSelection;
|
final selection = editorState.cursorSelection;
|
||||||
if (selection == null || selection.isCollapsed) {
|
if (selection == null || selection.isCollapsed) {
|
||||||
@ -60,10 +15,11 @@ _handleCopy(EditorState editorState) async {
|
|||||||
final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!;
|
final nodeAtPath = editorState.document.nodeAtPath(selection.end.path)!;
|
||||||
if (nodeAtPath.type == "text") {
|
if (nodeAtPath.type == "text") {
|
||||||
final textNode = nodeAtPath as TextNode;
|
final textNode = nodeAtPath as TextNode;
|
||||||
final delta =
|
final htmlString = NodesToHTMLConverter(
|
||||||
textNode.delta.slice(selection.start.offset, selection.end.offset);
|
nodes: [textNode],
|
||||||
|
startOffset: selection.start.offset,
|
||||||
final htmlString = stringify(deltaToHtml(delta));
|
endOffset: selection.end.offset)
|
||||||
|
.toHTMLString();
|
||||||
debugPrint('copy html: $htmlString');
|
debugPrint('copy html: $htmlString');
|
||||||
RichClipboard.setData(RichClipboardData(html: htmlString));
|
RichClipboard.setData(RichClipboardData(html: htmlString));
|
||||||
} else {
|
} else {
|
||||||
@ -74,32 +30,14 @@ _handleCopy(EditorState editorState) async {
|
|||||||
|
|
||||||
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
final beginNode = editorState.document.nodeAtPath(selection.start.path)!;
|
||||||
final endNode = editorState.document.nodeAtPath(selection.end.path)!;
|
final endNode = editorState.document.nodeAtPath(selection.end.path)!;
|
||||||
final traverser = NodeIterator(editorState.document, beginNode, endNode);
|
|
||||||
|
|
||||||
final nodes = <html.Node>[];
|
final nodes = NodeIterator(editorState.document, beginNode, endNode).toList();
|
||||||
while (traverser.moveNext()) {
|
|
||||||
final node = traverser.current;
|
|
||||||
if (node.type == "text") {
|
|
||||||
final textNode = node as TextNode;
|
|
||||||
String? subType = textNode.attributes["subtype"];
|
|
||||||
if (node == beginNode) {
|
|
||||||
final htmlElement =
|
|
||||||
deltaToHtml(textNode.delta.slice(selection.start.offset), subType);
|
|
||||||
nodes.add(htmlElement);
|
|
||||||
} else if (node == endNode) {
|
|
||||||
final htmlElement =
|
|
||||||
deltaToHtml(textNode.delta.slice(0, selection.end.offset), subType);
|
|
||||||
nodes.add(htmlElement);
|
|
||||||
} else {
|
|
||||||
final htmlElement = deltaToHtml(textNode.delta, subType);
|
|
||||||
nodes.add(htmlElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: handle image and other blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
final copyString = _HTMLNormalizer(nodes).normalize().fold<String>(
|
final copyString = NodesToHTMLConverter(
|
||||||
"", ((previousValue, element) => previousValue + stringify(element)));
|
nodes: nodes,
|
||||||
|
startOffset: selection.start.offset,
|
||||||
|
endOffset: selection.end.offset)
|
||||||
|
.toHTMLString();
|
||||||
debugPrint('copy html: $copyString');
|
debugPrint('copy html: $copyString');
|
||||||
RichClipboard.setData(RichClipboardData(html: copyString));
|
RichClipboard.setData(RichClipboardData(html: copyString));
|
||||||
}
|
}
|
||||||
@ -116,8 +54,7 @@ _pasteHTML(EditorState editorState, String html) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('paste html: $html');
|
debugPrint('paste html: $html');
|
||||||
final converter = HTMLConverter(html);
|
final nodes = HTMLToNodesConverter(html).toNodes();
|
||||||
final nodes = converter.toNodes();
|
|
||||||
|
|
||||||
if (nodes.isEmpty) {
|
if (nodes.isEmpty) {
|
||||||
return;
|
return;
|
||||||
|
Loading…
Reference in New Issue
Block a user