From bc7fbe130ac3b2742711778ae293e14fa2c33d64 Mon Sep 17 00:00:00 2001 From: Jim Tyrrell <4389749+JimTyrrell@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:34:33 -0700 Subject: [PATCH 1/5] Fix: Use consistent cursor height instead of per-character metrics The cursor height was calculated using getFullHeightForCaret() which returns different heights depending on the character at the cursor position. This caused the cursor to appear shorter when positioned after spaces compared to letters, especially noticeable on iOS. This fix uses a consistent formula (fontSize * 1.2) to ensure the cursor height remains stable regardless of the character at the cursor position. Fixes #1137 --- .../block_component/rich_text/appflowy_rich_text.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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; From ae545e465026ed31839be4569430ad806507bc19 Mon Sep 17 00:00:00 2001 From: Jim Tyrrell Date: Wed, 24 Jun 2026 16:01:09 -0600 Subject: [PATCH 2/5] fix(clipboard): retain rich html on copy so paste preserves formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppFlowyClipboard was a plain-text-only stub: setData dropped the `html` argument and getData hard-coded `html: null`. With no html on the clipboard, the editor's paste handler skipped its formatting-preserving `pasteHtml` branch and fell back to a bare plain-text delta — so copy→paste of formatted text (bold/italic/lists/headings) lost ALL formatting, even within the same app. Retain the last copied html in-process and return it on getData only when the system clipboard still holds the matching text we wrote (same-app copy→paste round trip); otherwise return html:null so external plain-text paste degrades gracefully. The system clipboard still receives text/plain as before, so cross-app plain paste is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/infra/clipboard.dart | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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, ); } From 0a751939fd14e88d4c38cfb5cf63ebaf65ff952a Mon Sep 17 00:00:00 2001 From: Jim Tyrrell Date: Wed, 24 Jun 2026 16:28:11 -0600 Subject: [PATCH 3/5] fix(html): preserve list nesting on copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML encoder appended a list item's child list nodes (bare
  • ) directly inside the parent
  • with no enclosing
      /
        . The emitted markup (
      • parent
      • child
      • ) is "fixed" by every HTML parser by flattening the inner
      • into a sibling — so a nested list item lost its indent level and was renumbered to top level on copy/paste. Add processChildrenNodesPreservingListNesting (wraps consecutive same-type list children in their own
          /
            ; non-list children pass through) and use it from the numbered + bulleted list parsers. Only affects lists WITH children (nested), so flat lists are unchanged. Checkbox/todo nesting is unchanged (separate follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../parser/bulleted_list_node_parser.dart | 2 +- .../html/encoder/parser/html_node_parser.dart | 57 +++++++++++++++++++ .../parser/numbered_list_node_parser.dart | 5 +- 3 files changed, 62 insertions(+), 2 deletions(-) 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, From 17c7b2dbecbe508e840e87866c17bbd4e3166ece Mon Sep 17 00:00:00 2001 From: Jim Tyrrell Date: Wed, 24 Jun 2026 16:39:58 -0600 Subject: [PATCH 4/5] fix(html): decode checkbox/todo-list on paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encoder emits a to-do item as
                text
                , but the decoder had no handler:
                fell through to the default (plain paragraph) and the was ignored — so pasting a checklist lost both the todo_list block type and the checked state. Add _parseDivElement: when a
                contains a checkbox , build a todo_list node carrying the checked state and the remaining inline content; otherwise fall back to paragraph parsing (non-todo divs unaffected). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/html/html_document_decoder.dart | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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')) { From b7775b731c1f337eee68e81de81d1f5504d87d86 Mon Sep 17 00:00:00 2001 From: Jim Tyrrell Date: Wed, 24 Jun 2026 16:46:43 -0600 Subject: [PATCH 5/5] test(html): checkbox/todo-list copy-paste round-trip regression Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/html/checkbox_roundtrip_test.dart | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/plugins/html/checkbox_roundtrip_test.dart 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'); + }); +}