Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Problem with the position of the floating operation bar #146

Merged
merged 5 commits into from
May 31, 2023

Conversation

LucasXu0
Copy link
Collaborator

Closes #142

@LucasXu0
Copy link
Collaborator Author

Closes #138

@LucasXu0
Copy link
Collaborator Author

closes #139

@alihassan143
Copy link
Contributor

@LucasXu0 html converter is not converting the document as expected
i checked you latest changes
but that was not working as expected
mostly things are done in that code but
nested attributes are not parsing as expected because just trying to figure if the text already exists in the delta than only add attributes in the than delta index

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);
  }

  Document toDocument() {
    final childNodes = _document.body?.nodes.toList() ?? <html.Node>[];

    return Document.fromJson({
      "document": {
        "type": "document",
        "children": _handleContainer(childNodes).map((e) => e.toJson()).toList()
      }
    });
  }

  List<Node> _handleContainer(List<html.Node> childNodes) {
    final delta = Delta();
    final result = <Node>[];
    for (final child in childNodes) {
      if (child is html.Element) {
        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.em ||
            child.localName == HTMLTag.del) {
          _handleRichTextElement(delta, child);
        } else if (child.localName == HTMLTag.bold) {
          // Google docs wraps the the content inside the `<b></b>` tag.
          // It's strange
          if (!_inParagraph) {
            result.addAll(_handleBTag(child));
          } else {
            result.add(_handleRichText(child, type: ParagraphBlockKeys.type));
          }
        } else if (child.localName == HTMLTag.blockQuote) {
          result.addAll(_handleBlockQuote(child));
        } else {
          result.addAll(_handleElement(child, type: ParagraphBlockKeys.type));
        }
      } else {
        delta.insert(child.text ?? "");
      }
    }
    if (delta.isNotEmpty) {
      result.add(
        Node(
          type: ParagraphBlockKeys.type,
          attributes: {ParagraphBlockKeys.delta: delta.toJson()},
        ),
      );
    }
    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(_handleListElement(child, type: QuoteBlockKeys.type));
      }
    }

    return result;
  }

  List<Node> _handleBTag(html.Element element) {
    final childNodes = element.nodes;
    return _handleContainer(childNodes);
  }

  List<Node> _handleElement(
    html.Element element, {
    Map<String, dynamic>? attributes,
    Delta? delta,
    required String type,
  }) {
    if (element.localName == HTMLTag.h1) {
      return [_handleHeadingElement(element, 1)];
    } else if (element.localName == HTMLTag.h2) {
      return [_handleHeadingElement(element, 2)];
    } else if (element.localName == HTMLTag.h3) {
      return [_handleHeadingElement(element, 3)];
    } else if (element.localName == HTMLTag.unorderedList) {
      return _handleUnorderedList(element);
    } else if (element.localName == HTMLTag.orderedList) {
      return _handleOrderedList(element);
    } else if (element.localName == HTMLTag.list) {
      return _handleListElement(
        element,
        type: type,
      );
    } else if (element.localName == HTMLTag.paragraph) {
      return [
        _handleParagraph(
          element,
          attributes: attributes,
          type: ParagraphBlockKeys.type,
        )
      ];
    } else if (element.localName == HTMLTag.image) {
      return [_handleImage(element)];
    } else if (element.localName == HTMLTag.divider) {
      return [_handleDivider()];
    } else if (delta != null) {
      _handleRichTextElement(delta, element);
      return [];
    } else {
      final delta = Delta();
      _handleRichTextElement(delta, element);

      return [
        Node(type: type, attributes: {ParagraphBlockKeys.delta: delta.toJson()})
      ];
    }
  }

  Node _handleParagraph(
    html.Element element, {
    Map<String, dynamic>? attributes,
    required String type,
  }) {
    _inParagraph = true;
    final node = _handleRichText(element, attributes: attributes, type: type);
    _inParagraph = false;
    return node;
  }

  Node _handleDivider() => Node(type: 'divider');

  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].trim()] = tuples[1].trim();
    }

    return result;
  }

  Attributes? _getDeltaAttributesFromHtmlAttributes(
    LinkedHashMap<Object, String> htmlAttributes,
  ) {
    final attrs = <String, dynamic>{};
    final styleString = htmlAttributes["style"];
    final cssMap = _cssStringToMap(styleString);

    final fontWeightStr = cssMap["font-weight"];
    if (fontWeightStr != null) {
      if (fontWeightStr == "bold") {
        attrs[BuiltInAttributeKey.bold] = true;
      } else {
        int? weight = int.tryParse(fontWeightStr);
        if (weight != null && weight > 500) {
          attrs[BuiltInAttributeKey.bold] = true;
        }
      }
    }

    final textDecorationStr = cssMap["text-decoration"];
    if (textDecorationStr != null) {
      _assignTextDecorations(attrs, textDecorationStr);
    }

    final backgroundColorStr = cssMap["background-color"];
    final backgroundColor = backgroundColorStr == null
        ? null
        : ColorExtension2.tryFromRgbaString(backgroundColorStr);
    if (backgroundColor != null) {
      attrs[BuiltInAttributeKey.highlightColor] =
          '0x${backgroundColor.value.toRadixString(16)}';
    }

    if (cssMap["font-style"] == "italic") {
      attrs[BuiltInAttributeKey.italic] = true;
    }

    return attrs.isEmpty ? null : attrs;
  }

  void _assignTextDecorations(Attributes attrs, String decorationStr) {
    final decorations = decorationStr.split(" ");
    for (final d in decorations) {
      if (d == "line-through") {
        attrs[BuiltInAttributeKey.strikethrough] = true;
      } else if (d == "underline") {
        attrs[BuiltInAttributeKey.underline] = true;
      }
    }
  }

  void _handleRichTextElement(Delta delta, html.Element element) {
    Map<String, dynamic> attributes = {};
    if (element.localName == HTMLTag.span) {
      attributes.addAll(
        _getDeltaAttributesFromHtmlAttributes(element.attributes) ?? {},
      );
    } else if (element.localName == HTMLTag.anchor) {
      final hyperLink = element.attributes["href"];
      attributes.addAll(attributes = {"href": hyperLink});

      delta.insert("${element.text} ", attributes: attributes);
    } else if (element.localName == HTMLTag.strong ||
        element.localName == HTMLTag.bold) {
      attributes.addAll(
        {BuiltInAttributeKey.bold: true},
      );
    } else if ([HTMLTag.em, HTMLTag.italic].contains(element.localName)) {
      attributes.addAll(
        {BuiltInAttributeKey.italic: true},
      );
    } else if (element.localName == HTMLTag.underline) {
      attributes.addAll(
        {BuiltInAttributeKey.underline: true},
      );
    } else if ([HTMLTag.italic, HTMLTag.em].contains(element.localName)) {
      attributes.addAll(
        {BuiltInAttributeKey.italic: true},
      );
    } else if (element.localName == HTMLTag.del) {
      attributes.addAll(
        {BuiltInAttributeKey.strikethrough: true},
      );
    } else if (element.localName == HTMLTag.code) {
      attributes.addAll(
        {BuiltInAttributeKey.code: true},
      );
    }
    List<TextInsert> text = delta.map((e) => e as TextInsert).toList();
    bool isExist =
        text.where((textinsert) => textinsert.text == element.text).isNotEmpty;
    if (isExist) {
      TextInsert insterTextElement =
          text.where((textinsert) => textinsert.text == element.text).first;
      text[text.indexOf(insterTextElement)].attributes?.addAll(attributes);
    } else {
      delta.insert("${element.text} ", attributes: attributes);
    }

    for (var newelement in element.children) {
      _handleRichTextElement(delta, newelement);
    }
  }

  /// 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,
    required String type,
  }) {
    final image = element.querySelector(HTMLTag.image);
    if (image != null) {
      final imageNode = _handleImage(image);
      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";
    }

    final delta = Delta();

    for (final child in element.nodes.toList()) {
      if (child is html.Element) {
        _handleRichTextElement(delta, child);
      } else {
        delta.insert(child.text ?? " ");
      }
    }

    final textNode = Node(
      type: type,
      attributes: {
        if (attributes != null) ...attributes,
        if (isCheckbox) ...{
          TodoListBlockKeys.checked: checked,
        },
        ParagraphBlockKeys.delta: delta.toJson()
      },
    );

    return textNode;
  }

  Node _handleImage(html.Element element) {
    final src = element.attributes["src"];
    final alignment = element.attributes["align"] ?? "center";
    final width = element.attributes["width"] != null
        ? double.parse(element.attributes["width"].toString())
        : 200;
    final height = element.attributes["height"] != null
        ? double.parse(element.attributes["height"].toString())
        : 100;
    final attributes = <String, dynamic>{};
    if (src != null) {
      attributes[ImageBlockKeys.url] = src;
      attributes[ImageBlockKeys.align] = alignment;
      attributes[ImageBlockKeys.width] = width;
      attributes[ImageBlockKeys.height] = height;
      attributes[ParagraphBlockKeys.delta] = Delta().toJson();
    }
    return Node(type: ImageBlockKeys.type, attributes: attributes);
  }

  List<Node> _handleUnorderedList(html.Element element) {
    final result = <Node>[];
    for (var child in element.children) {
      result
          .addAll(_handleListElement(child, type: BulletedListBlockKeys.type));
    }
    return result;
  }

  List<Node> _handleOrderedList(html.Element element) {
    final result = <Node>[];
    for (var child in element.children) {
      result.addAll(
        _handleListElement(
          child,
          type: NumberedListBlockKeys.type,
        ),
      );
    }
    return result;
  }

  Node _handleHeadingElement(
    html.Element element,
    int level,
  ) {
    final delta = Delta();
    _handleRichTextElement(delta, element);

    return Node(
      type: HeadingBlockKeys.type,
      attributes: {
        HeadingBlockKeys.level: level,
        HeadingBlockKeys.delta: delta.toJson()
      },
    );
  }

  List<Node> _handleListElement(
    html.Element element, {
    Map<String, dynamic>? attributes,
    required String type,
  }) {
    final childNodes = element.nodes.toList();
    final delta = Delta();
    for (final child in childNodes) {
      if (child is html.Text) {
        delta.insert(child.text);
      } else if (child is html.Element) {
        _handleElement(
          child,
          attributes: attributes,
          delta: delta,
          type: type,
        );
      }
    }
    return [
      Node(type: type, attributes: {ParagraphBlockKeys.delta: delta.toJson()})
    ];
  }
}

@alihassan143
Copy link
Contributor

Html Sample Code for Text

 <p>&nbsp;</p>
      <h1>AppFlowy Editor is a <strong><em>highly</em> customizable</strong> <em>rich-text editor</em> for&nbsp; <strong><em><u>Flutter</u></em></strong></h1>
      <ol>
      <li>You <em>can</em> <strong>also</strong> use <span style="background-color: rgba(0, 188, 240, 96); font-weight: bold; font-style: italic;">AppFlowy Editor</span> as a component to build your own app.</li>
      <li>ertteterttet</li>
      <li>If you have questions or <strong>feedback</strong>, please submit an issue on Github or join the community along with 1000+ builders!</li>
      </ol>
      <ul>
      <li>You&nbsp;<em>can</em>&nbsp;<strong>also</strong>&nbsp;use&nbsp;<span style="background-color: rgba(0, 188, 240, 96); font-weight: bold; font-style: italic;">AppFlowy Editor</span>&nbsp;as a component to build your own app.</li>
      <li>ertteterttet</li>
      <li>If you have questions or&nbsp;<strong>feedback</strong>, please submit an issue on Github or join the community along with 1000+ builders!</li>
      <li>&nbsp;</li>
      <li><strong>abdibabdjnaodn</strong></li>
      </ul>
      <p><em>andkajskndljsadl</em></p>
      <p><em><strong><span style="text-decoration: underline;">bnaksdnlasdnlasnldnl.&nbsp; &nbsp;</span></strong></em></p>
      <p><em><strong><span style="text-decoration: underline;">asoidasjda</span></strong></em></p>
      <p><em><strong><span style="text-decoration: underline;">ashjdjasdnkasdad</span></strong></em></p>
      <p><em><strong><span style="text-decoration: underline;">knajsdlasdlad</span></strong></em></p>

@alihassan143
Copy link
Contributor

Most of the things done just check delta already contains that text
then only add latest attributes in its existing attributes

void _handleRichTextElement(Delta delta, html.Element element) {
    Map<String, dynamic> attributes = {};
    if (element.localName == HTMLTag.span) {
      attributes.addAll(
        _getDeltaAttributesFromHtmlAttributes(element.attributes) ?? {},
      );
    } else if (element.localName == HTMLTag.anchor) {
      final hyperLink = element.attributes["href"];
      attributes.addAll(attributes = {"href": hyperLink});

      delta.insert("${element.text} ", attributes: attributes);
    } else if (element.localName == HTMLTag.strong ||
        element.localName == HTMLTag.bold) {
      attributes.addAll(
        {BuiltInAttributeKey.bold: true},
      );
    } else if ([HTMLTag.em, HTMLTag.italic].contains(element.localName)) {
      attributes.addAll(
        {BuiltInAttributeKey.italic: true},
      );
    } else if (element.localName == HTMLTag.underline) {
      attributes.addAll(
        {BuiltInAttributeKey.underline: true},
      );
    } else if ([HTMLTag.italic, HTMLTag.em].contains(element.localName)) {
      attributes.addAll(
        {BuiltInAttributeKey.italic: true},
      );
    } else if (element.localName == HTMLTag.del) {
      attributes.addAll(
        {BuiltInAttributeKey.strikethrough: true},
      );
    } else if (element.localName == HTMLTag.code) {
      attributes.addAll(
        {BuiltInAttributeKey.code: true},
      );
    }
    List<TextInsert> text = delta.map((e) => e as TextInsert).toList();
    bool isExist =
        text.where((textinsert) => textinsert.text == element.text).isNotEmpty;
    if (isExist) {
      TextInsert insterTextElement =
          text.where((textinsert) => textinsert.text == element.text).first;
      text[text.indexOf(insterTextElement)].attributes?.addAll(attributes);
    } else {
      delta.insert("${element.text} ", attributes: attributes);
    }

    for (var newelement in element.children) {
      _handleRichTextElement(delta, newelement);
    }
  }

@alihassan143
Copy link
Contributor

@LucasXu0 is their a way to check delta certain index text is equal to element text?

@LucasXu0
Copy link
Collaborator Author

I added a new class to handle the html convert. would you mind helping me to optimize it?

@LucasXu0
Copy link
Collaborator Author

@LucasXu0 is their a way to check delta certain index text is equal to element text?

you can slice the delta.

@LucasXu0
Copy link
Collaborator Author

The implementation of the HTML parser is currently very rough, and I don't have time to add more test cases for it at the moment.

@codecov
Copy link

codecov bot commented May 31, 2023

Codecov Report

Merging #146 (f41b340) into main (ae8548c) will decrease coverage by 2.44%.
The diff coverage is 16.29%.

@@            Coverage Diff             @@
##             main     #146      +/-   ##
==========================================
- Coverage   60.14%   57.70%   -2.44%     
==========================================
  Files         208      211       +3     
  Lines        9343     9526     +183     
==========================================
- Hits         5619     5497     -122     
- Misses       3724     4029     +305     
Impacted Files Coverage Δ
...block_component/numbered_list_block_component.dart 80.00% <0.00%> (ø)
...ortcuts/command_shortcut_events/paste_command.dart 15.00% <0.00%> (-3.75%) ⬇️
...b/src/editor/toolbar/desktop/floating_toolbar.dart 1.04% <0.00%> (-0.18%) ⬇️
lib/src/editor/util/color_util.dart 28.57% <0.00%> (-46.43%) ⬇️
lib/src/editor_state.dart 80.99% <0.00%> (-2.06%) ⬇️
lib/src/plugins/html/html_document_decoder.dart 0.00% <0.00%> (ø)
lib/src/plugins/html/html_document_encoder.dart 0.00% <0.00%> (ø)
lib/src/plugins/html/html_document.dart 11.11% <11.11%> (ø)
lib/src/infra/html_converter.dart 49.70% <56.25%> (-37.62%) ⬇️
lib/src/core/document/document.dart 81.81% <100.00%> (+0.33%) ⬆️
... and 5 more

@alihassan143
Copy link
Contributor

I added a new class to handle the html convert. would you mind helping me to optimize it?

yeh i will help you

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug] Problem with the position of the floating operation bar
2 participants