Merge pull request #849 from AppFlowy-IO/feat/copy-styles

Feat: copy h1/h2/h3 styles
This commit is contained in:
Vincent Chan 2022-08-15 15:47:06 +08:00 committed by GitHub
commit a23113e48d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 142 additions and 340 deletions

View File

@ -1,245 +0,0 @@
{
"document": {
"type": "editor",
"attributes": {},
"children": [
{
"type": "image",
"attributes": {
"image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png?format=1500w"
}
},
{
"type": "text",
"delta": [
{
"insert": "👋 Welcome to AppFlowy!",
"attributes": {
"href": "https://www.appflowy.io/",
"heading": "h1"
}
}
],
"attributes": {
"heading": "h1"
}
},
{
"type": "text",
"delta": [
{ "insert": "Here are the basics", "attributes": { "heading": "h2" } }
],
"attributes": {
"heading": "h2"
}
},
{
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"list": "todo",
"todo": false
}
},
{
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"list": "bullet"
}
},
{
"type": "text",
"delta": [{ "insert": "Click anywhere and just start typing." }],
"attributes": {
"list": "bullet"
}
},
{
"type": "text",
"delta": [
{
"insert": "Highlight",
"attributes": { "highlight": "0xFFFFFF00" }
},
{ "insert": " Click anywhere and just start typing" },
{ "insert": " any text, and use the menu at the bottom to " },
{ "insert": "style", "attributes": { "italic": true } },
{ "insert": " your ", "attributes": { "bold": true } },
{ "insert": "writing", "attributes": { "underline": true } },
{
"insert": " however you like.",
"attributes": { "strikethrough": true }
}
],
"attributes": {
"checkbox": false
}
},
{
"type": "text",
"delta": [
{ "insert": "Have a question? ", "attributes": { "heading": "h2" } }
],
"attributes": {
"heading": "h2"
}
},
{
"type": "text",
"delta": [
{
"insert": "1. Click the '?' at the bottom right for help and support."
}
],
"attributes": {
"quotes": true
}
},
{
"type": "text",
"delta": [
{
"insert": "2. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "3. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "4. Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support.Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "5. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "6. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "7. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "8. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "9. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "10. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "11. Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
},
{
"type": "text",
"delta": [
{
"insert": "Click the '?' at the bottom right for help and support."
}
],
"attributes": {}
}
]
}
}

View File

@ -3,7 +3,7 @@ description: A new Flutter project.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
publish_to: "none" # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
@ -30,7 +30,6 @@ dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
@ -58,7 +57,6 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
@ -66,7 +64,6 @@ flutter:
# To add assets to your application, add an assets section, like this:
assets:
- document.json
- example.json
- big_document.json
# - images/a_dot_ham.jpeg

View File

@ -0,0 +1,36 @@
import 'package:flutter/painting.dart';
extension ColorExtension on Color {
/// Try to parse the `rgba(red, greed, blue, alpha)`
/// from the string.
static Color? tryFromRgbaString(String colorString) {
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);
}
String toRgbaString() {
return 'rgba($red, $green, $blue, $alpha)';
}
}

View File

@ -1,33 +1,42 @@
import 'dart:collection';
import 'dart:ui';
import 'package:flowy_editor/src/document/attributes.dart';
import 'package:flowy_editor/src/document/node.dart';
import 'package:flowy_editor/src/document/text_delta.dart';
import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flowy_editor/src/extensions/color_extension.dart';
import 'package:flutter/material.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 tagOrderedList = "ol";
const String tagUnorderedList = "ul";
const String tagList = "li";
const String tagParagraph = "p";
const String tagImage = "img";
const String tagAnchor = "a";
const String tagItalic = "i";
const String tagBold = "b";
const String tagUnderline = "u";
const String tagDel = "del";
const String tagStrong = "strong";
const String tagSpan = "span";
const String tagCode = "code";
class HTMLTag {
static const h1 = "h1";
static const h2 = "h2";
static const h3 = "h3";
static const orderedList = "ol";
static const unorderedList = "ul";
static const list = "li";
static const paragraph = "p";
static const image = "img";
static const anchor = "a";
static const italic = "i";
static const bold = "b";
static const underline = "u";
static const del = "del";
static const strong = "strong";
static const span = "span";
static const code = "code";
static const blockQuote = "blockquote";
static const div = "div";
extension on Color {
String toRgbaString() {
return 'rgba($red, $green, $blue, $alpha)';
static bool isTopLevel(String tag) {
return tag == h1 ||
tag == h2 ||
tag == h3 ||
tag == paragraph ||
tag == div ||
tag == blockQuote;
}
}
@ -54,15 +63,15 @@ class HTMLToNodesConverter {
final result = <Node>[];
for (final child in childNodes) {
if (child is html.Element) {
if (child.localName == tagAnchor ||
child.localName == tagSpan ||
child.localName == tagCode ||
child.localName == tagStrong ||
child.localName == tagUnderline ||
child.localName == tagItalic ||
child.localName == tagDel) {
if (child.localName == HTMLTag.anchor ||
child.localName == HTMLTag.span ||
child.localName == HTMLTag.code ||
child.localName == HTMLTag.strong ||
child.localName == HTMLTag.underline ||
child.localName == HTMLTag.italic ||
child.localName == HTMLTag.del) {
_handleRichTextElement(delta, child);
} else if (child.localName == tagBold) {
} else if (child.localName == HTMLTag.bold) {
// Google docs wraps the the content inside the `<b></b>` tag.
// It's strange
if (!_inParagraph) {
@ -70,6 +79,8 @@ class HTMLToNodesConverter {
} else {
result.add(_handleRichText(child));
}
} else if (child.localName == HTMLTag.blockQuote) {
result.addAll(_handleBlockQuote(child));
} else {
result.addAll(_handleElement(child));
}
@ -83,6 +94,18 @@ class HTMLToNodesConverter {
return result;
}
List<Node> _handleBlockQuote(html.Element element) {
final result = <Node>[];
for (final child in element.nodes.toList()) {
if (child is html.Element) {
result.addAll(_handleElement(child, {"subtype": StyleKey.quote}));
}
}
return result;
}
List<Node> _handleBTag(html.Element element) {
final childNodes = element.nodes;
return _handleContainer(childNodes);
@ -90,19 +113,19 @@ class HTMLToNodesConverter {
List<Node> _handleElement(html.Element element,
[Map<String, dynamic>? attributes]) {
if (element.localName == tagH1) {
return [_handleHeadingElement(element, tagH1)];
} else if (element.localName == tagH2) {
return [_handleHeadingElement(element, tagH2)];
} else if (element.localName == tagH3) {
return [_handleHeadingElement(element, tagH3)];
} else if (element.localName == tagUnorderedList) {
if (element.localName == HTMLTag.h1) {
return [_handleHeadingElement(element, HTMLTag.h1)];
} else if (element.localName == HTMLTag.h2) {
return [_handleHeadingElement(element, HTMLTag.h2)];
} else if (element.localName == HTMLTag.h3) {
return [_handleHeadingElement(element, HTMLTag.h3)];
} else if (element.localName == HTMLTag.unorderedList) {
return _handleUnorderedList(element);
} else if (element.localName == tagOrderedList) {
} else if (element.localName == HTMLTag.orderedList) {
return _handleOrderedList(element);
} else if (element.localName == tagList) {
} else if (element.localName == HTMLTag.list) {
return _handleListElement(element);
} else if (element.localName == tagParagraph) {
} else if (element.localName == HTMLTag.paragraph) {
return [_handleParagraph(element, attributes)];
} else {
final delta = Delta();
@ -164,7 +187,9 @@ class HTMLToNodesConverter {
}
final backgroundColorStr = cssMap["background-color"];
final backgroundColor = _tryParseCssColorString(backgroundColorStr);
final backgroundColor = backgroundColorStr == null
? null
: ColorExtension.tryFromRgbaString(backgroundColorStr);
if (backgroundColor != null) {
attrs[StyleKey.backgroundColor] =
'0x${backgroundColor.value.toRadixString(16)}';
@ -188,56 +213,25 @@ class HTMLToNodesConverter {
}
}
/// 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) {
if (element.localName == tagSpan) {
if (element.localName == HTMLTag.span) {
delta.insert(element.text,
_getDeltaAttributesFromHtmlAttributes(element.attributes));
} else if (element.localName == tagAnchor) {
} else if (element.localName == HTMLTag.anchor) {
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) {
} else if (element.localName == HTMLTag.strong ||
element.localName == HTMLTag.bold) {
delta.insert(element.text, {StyleKey.bold: true});
} else if (element.localName == tagUnderline) {
} else if (element.localName == HTMLTag.underline) {
delta.insert(element.text, {StyleKey.underline: true});
} else if (element.localName == tagItalic) {
} else if (element.localName == HTMLTag.italic) {
delta.insert(element.text, {StyleKey.italic: true});
} else if (element.localName == tagDel) {
} else if (element.localName == HTMLTag.del) {
delta.insert(element.text, {StyleKey.strikethrough: true});
} else {
delta.insert(element.text);
@ -250,7 +244,7 @@ class HTMLToNodesConverter {
/// A container contains a <img /> will be regarded as a image block
Node _handleRichText(html.Element element,
[Map<String, dynamic>? attributes]) {
final image = element.querySelector(tagImage);
final image = element.querySelector(HTMLTag.image);
if (image != null) {
final imageNode = _handleImage(image);
return imageNode;
@ -404,10 +398,10 @@ class NodesToHTMLConverter {
}
_addElement(TextNode textNode, html.Element element) {
if (element.localName == tagList) {
if (element.localName == HTMLTag.list) {
final isNumbered = textNode.attributes["subtype"] == StyleKey.numberList;
_stashListContainer ??=
html.Element.tag(isNumbered ? tagOrderedList : tagUnorderedList);
_stashListContainer ??= html.Element.tag(
isNumbered ? HTMLTag.orderedList : HTMLTag.unorderedList);
_stashListContainer?.append(element);
} else {
if (_stashListContainer != null) {
@ -427,8 +421,10 @@ class NodesToHTMLConverter {
html.Element _textNodeToHtml(TextNode textNode, {int? end}) {
String? subType = textNode.attributes["subtype"];
String? heading = textNode.attributes["heading"];
return _deltaToHtml(textNode.delta,
subType: subType,
heading: heading,
end: end,
checked: textNode.attributes["checkbox"] == true);
}
@ -501,22 +497,32 @@ class NodesToHTMLConverter {
/// <span style="...">Text</span>
/// ```
html.Element _deltaToHtml(Delta delta,
{String? subType, int? end, bool? checked}) {
{String? subType, String? heading, int? end, bool? checked}) {
if (end != null) {
delta = delta.slice(0, end);
}
final childNodes = <html.Node>[];
String tagName = tagParagraph;
String tagName = HTMLTag.paragraph;
if (subType == StyleKey.bulletedList || subType == StyleKey.numberList) {
tagName = tagList;
tagName = HTMLTag.list;
} else if (subType == StyleKey.checkbox) {
final node = html.Element.html('<input type="checkbox" />');
if (checked != null && checked) {
node.attributes["checked"] = "true";
}
childNodes.add(node);
} else if (subType == StyleKey.heading) {
if (heading == StyleKey.h1) {
tagName = HTMLTag.h1;
} else if (heading == StyleKey.h2) {
tagName = HTMLTag.h2;
} else if (heading == StyleKey.h3) {
tagName = HTMLTag.h3;
}
} else if (subType == StyleKey.quote) {
tagName = HTMLTag.blockQuote;
}
for (final op in delta) {
@ -524,26 +530,26 @@ class NodesToHTMLConverter {
final attributes = op.attributes;
if (attributes != null) {
if (attributes.length == 1 && attributes[StyleKey.bold] == true) {
final strong = html.Element.tag(tagStrong);
final strong = html.Element.tag(HTMLTag.strong);
strong.append(html.Text(op.content));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[StyleKey.underline] == true) {
final strong = html.Element.tag(tagUnderline);
final strong = html.Element.tag(HTMLTag.underline);
strong.append(html.Text(op.content));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[StyleKey.italic] == true) {
final strong = html.Element.tag(tagItalic);
final strong = html.Element.tag(HTMLTag.italic);
strong.append(html.Text(op.content));
childNodes.add(strong);
} else if (attributes.length == 1 &&
attributes[StyleKey.strikethrough] == true) {
final strong = html.Element.tag(tagDel);
final strong = html.Element.tag(HTMLTag.del);
strong.append(html.Text(op.content));
childNodes.add(strong);
} else {
final span = html.Element.tag(tagSpan);
final span = html.Element.tag(HTMLTag.span);
final cssString = _attributesToCssStyle(attributes);
if (cssString.isNotEmpty) {
span.attributes["style"] = cssString;
@ -557,12 +563,20 @@ class NodesToHTMLConverter {
}
}
if (tagName != tagParagraph) {
final p = html.Element.tag(tagParagraph);
if (tagName == HTMLTag.blockQuote) {
final p = html.Element.tag(HTMLTag.paragraph);
for (final node in childNodes) {
p.append(node);
}
final result = html.Element.tag(tagList);
final blockQuote = html.Element.tag(tagName);
blockQuote.append(p);
return blockQuote;
} else if (!HTMLTag.isTopLevel(tagName)) {
final p = html.Element.tag(HTMLTag.paragraph);
for (final node in childNodes) {
p.append(node);
}
final result = html.Element.tag(HTMLTag.list);
result.append(p);
return result;
} else {