diff --git a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart index e859b513b..11c03fc15 100644 --- a/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart +++ b/lib/src/editor/block_component/rich_text/appflowy_rich_text.dart @@ -221,8 +221,12 @@ class _AppFlowyRichTextState extends State } } - double? cursorHeight = - _renderParagraph?.getFullHeightForCaret(textPosition); + // Fix: Use consistent cursor height based on font metrics instead of + // getFullHeightForCaret() which returns different heights for different + // characters (especially spaces vs letters on iOS). + // See: https://github.com/AppFlowy-IO/appflowy-editor/issues/1137 + final fontSize = textStyleConfiguration.text.fontSize ?? 16.0; + double? cursorHeight = fontSize * 1.2; Offset? cursorOffset = _renderParagraph?.getOffsetForCaret(textPosition, Rect.zero) ?? Offset.zero; diff --git a/lib/src/infra/clipboard.dart b/lib/src/infra/clipboard.dart index ba302a2c4..24f2949cf 100644 --- a/lib/src/infra/clipboard.dart +++ b/lib/src/infra/clipboard.dart @@ -16,6 +16,22 @@ class AppFlowyClipboard { @visibleForTesting static String? lastText; + // In-process rich-content fallback. + // + // Flutter's system `Clipboard` only carries `text/plain`, so the rich `html` + // computed on copy was previously dropped on the floor (`setData` ignored it + // and `getData` hard-coded `html: null`). With no html, the paste handler + // skips its formatting-preserving `pasteHtml` branch and falls back to a + // bare, attribute-less plain-text delta — so a copy→paste of formatted text + // (bold/italic/lists/etc.) lost ALL formatting, even within this same app. + // + // We retain the last copied html here and hand it back on `getData` ONLY when + // the system clipboard still holds the exact text we last wrote — i.e. this + // is a same-app copy→paste round trip. If another app replaced the clipboard, + // the text won't match and we correctly return `html: null` (plain paste), + // so external plain-text paste still degrades gracefully. + static String? _lastHtml; + static Future setData({ String? text, String? html, @@ -25,6 +41,7 @@ class AppFlowyClipboard { } lastText = text; + _lastHtml = html; return Clipboard.setData( ClipboardData( @@ -39,10 +56,19 @@ class AppFlowyClipboard { } final data = await Clipboard.getData(Clipboard.kTextPlain); + final systemText = data?.text; + + // Reattach the retained html only for a same-app copy→paste round trip + // (system clipboard text still equals what we last copied). Otherwise the + // clipboard was replaced externally → return plain (html: null). + final html = + (_lastHtml != null && systemText != null && systemText == lastText) + ? _lastHtml + : null; return AppFlowyClipboardData( - text: data?.text, - html: null, + text: systemText, + html: html, ); } diff --git a/lib/src/plugins/html/encoder/parser/bulleted_list_node_parser.dart b/lib/src/plugins/html/encoder/parser/bulleted_list_node_parser.dart index 16ff359cd..f165e2148 100644 --- a/lib/src/plugins/html/encoder/parser/bulleted_list_node_parser.dart +++ b/lib/src/plugins/html/encoder/parser/bulleted_list_node_parser.dart @@ -40,7 +40,7 @@ class HTMLBulletedListNodeParser extends HTMLNodeParser { final delta = node.delta ?? Delta(); final domNodes = deltaHTMLEncoder.convert(delta); domNodes.addAll( - processChildrenNodes( + processChildrenNodesPreservingListNesting( node.children, encodeParsers: encodeParsers, ), diff --git a/lib/src/plugins/html/encoder/parser/html_node_parser.dart b/lib/src/plugins/html/encoder/parser/html_node_parser.dart index bb2224ce5..2f99b3cff 100644 --- a/lib/src/plugins/html/encoder/parser/html_node_parser.dart +++ b/lib/src/plugins/html/encoder/parser/html_node_parser.dart @@ -54,6 +54,63 @@ abstract class HTMLNodeParser { return result; } + /// Like [processChildrenNodes] but preserves list NESTING. + /// + /// A list item's child list nodes (`transformNodeToDomNodes` returns a bare + /// `
  • `) must be wrapped in their own `
      `/`
        ` — otherwise the emitted + /// HTML is `
      • parent
      • child
      • `, which every HTML parser "fixes" + /// by flattening the inner `
      • ` into a sibling, collapsing the nesting. + /// Consecutive same-type list children share one wrapper; non-list children + /// pass through unchanged. + List processChildrenNodesPreservingListNesting( + Iterable nodes, { + required List encodeParsers, + }) { + final result = []; + dom.Element? openList; + String? openListTag; + + void flush() { + if (openList != null) { + result.add(openList!); + openList = null; + openListTag = null; + } + } + + for (final node in nodes) { + final parser = encodeParsers.firstWhereOrNull( + (element) => element.id == node.type, + ); + if (parser == null) { + continue; + } + final childDom = + parser.transformNodeToDomNodes(node, encodeParsers: encodeParsers); + final String? listTag = node.type == NumberedListBlockKeys.type + ? HTMLTags.orderedList + : node.type == BulletedListBlockKeys.type + ? HTMLTags.unorderedList + : null; + if (listTag == null) { + flush(); + result.addAll(childDom); + continue; + } + if (openListTag != listTag) { + flush(); + openList = dom.Element.tag(listTag); + openListTag = listTag; + } + for (final n in childDom) { + openList!.append(n); + } + } + flush(); + + return result; + } + String toHTMLString(List nodes) => nodes.map((e) => stringify(e)).join().replaceAll('\n', ''); } diff --git a/lib/src/plugins/html/encoder/parser/numbered_list_node_parser.dart b/lib/src/plugins/html/encoder/parser/numbered_list_node_parser.dart index b998bab7c..df2ff9786 100644 --- a/lib/src/plugins/html/encoder/parser/numbered_list_node_parser.dart +++ b/lib/src/plugins/html/encoder/parser/numbered_list_node_parser.dart @@ -41,7 +41,10 @@ class HTMLNumberedListNodeParser extends HTMLNodeParser { final delta = node.delta ?? Delta(); final domNodes = deltaHTMLEncoder.convert(delta); domNodes.addAll( - processChildrenNodes(node.children, encodeParsers: encodeParsers), + processChildrenNodesPreservingListNesting( + node.children, + encodeParsers: encodeParsers, + ), ); final element = wrapChildrenNodesWithTagName( HTMLTags.list, diff --git a/lib/src/plugins/html/html_document_decoder.dart b/lib/src/plugins/html/html_document_decoder.dart index 161aca0ab..cef9be4e5 100644 --- a/lib/src/plugins/html/html_document_decoder.dart +++ b/lib/src/plugins/html/html_document_decoder.dart @@ -145,6 +145,9 @@ class DocumentHTMLDecoder extends Converter { ), ]; + case HTMLTags.div: + return _parseDivElement(element); + case HTMLTags.paragraph: return _parseParagraphElement(element); @@ -423,6 +426,62 @@ class DocumentHTMLDecoder extends Converter { return [paragraphNode(delta: delta), ...specialNodes]; } + /// Parse a `
        ` that may be a to-do / checkbox item: + /// `
        text…
        ` + /// (the shape emitted by HTMLTodoListNodeParser). When a checkbox `` + /// is present, build a `todo_list` node carrying the checked state and the + /// remaining inline content; otherwise fall back to treating the div as a + /// paragraph (prior behaviour — so non-todo `
        `s from other sources are + /// unaffected). + Iterable _parseDivElement(dom.Element element) { + dom.Element? input; + for (final child in element.children) { + if (child.localName == HTMLTags.checkbox) { + input = child; + break; + } + } + if (input == null) { + return _parseParagraphElement(element); + } + + final checked = input.attributes.containsKey('checked'); + final delta = Delta(); + final children = []; + for (final child in element.nodes) { + if (child is dom.Element) { + if (child.localName == HTMLTags.checkbox) { + continue; // the checkbox marker itself + } + if (HTMLTags.formattingElements.contains(child.localName)) { + final attributes = _parserFormattingElementAttributes(child); + delta.insert(child.text, attributes: attributes); + } else if (HTMLTags.specialElements.contains(child.localName)) { + children.addAll( + _parseSpecialElements(child, type: TodoListBlockKeys.type), + ); + } else { + delta.insert(child.text); + } + } else if (child is dom.Text) { + if (child.text.trim().isNotEmpty) { + delta.insert(child.text); + } + } + } + + return [ + Node( + type: TodoListBlockKeys.type, + attributes: { + TodoListBlockKeys.checked: checked, + ParagraphBlockKeys.delta: delta.toJson(), + }, + children: children, + ), + ]; + } + Node _parseImageElement(dom.Element element) { final src = element.attributes['src']; if (src == null || src.isEmpty || !src.startsWith('http')) { diff --git a/test/plugins/html/checkbox_roundtrip_test.dart b/test/plugins/html/checkbox_roundtrip_test.dart new file mode 100644 index 000000000..3867feb9f --- /dev/null +++ b/test/plugins/html/checkbox_roundtrip_test.dart @@ -0,0 +1,32 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // Regression: copy→paste of a checklist must preserve the todo_list block + // type AND the checked state. The encoder emits + //
        text
        ; the decoder must rebuild + // a todo_list node (previously
        fell through to a plain paragraph and the + // was ignored, so checkboxes pasted as plain text). + test('todo_list survives html encode -> decode with checked state', () { + final doc = Document.blank(withInitialText: false) + ..insert([0], [ + todoListNode(checked: false, text: 'check box 1'), + todoListNode(checked: true, text: 'check box 2'), + todoListNode(checked: false, text: 'check box 3'), + ]); + + final decoded = htmlToDocument(documentToHTML(doc)); + final children = decoded.root.children.toList(); + + expect(children.length, 3); + expect( + children.every((n) => n.type == TodoListBlockKeys.type), + isTrue, + reason: 'all three should decode as todo_list, not paragraph', + ); + expect(children[0].attributes[TodoListBlockKeys.checked], false); + expect(children[1].attributes[TodoListBlockKeys.checked], true); + expect(children[2].attributes[TodoListBlockKeys.checked], false); + expect(children[1].delta?.toPlainText(), 'check box 2'); + }); +}