Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,12 @@ class _AppFlowyRichTextState extends State<AppFlowyRichText>
}
}

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;
Expand Down
30 changes: 28 additions & 2 deletions lib/src/infra/clipboard.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> setData({
String? text,
String? html,
Expand All @@ -25,6 +41,7 @@ class AppFlowyClipboard {
}

lastText = text;
_lastHtml = html;

return Clipboard.setData(
ClipboardData(
Expand All @@ -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,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
57 changes: 57 additions & 0 deletions lib/src/plugins/html/encoder/parser/html_node_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// `<li>`) must be wrapped in their own `<ol>`/`<ul>` — otherwise the emitted
/// HTML is `<li>parent<li>child</li></li>`, which every HTML parser "fixes"
/// by flattening the inner `<li>` into a sibling, collapsing the nesting.
/// Consecutive same-type list children share one wrapper; non-list children
/// pass through unchanged.
List<dom.Node> processChildrenNodesPreservingListNesting(
Iterable<Node> nodes, {
required List<HTMLNodeParser> encodeParsers,
}) {
final result = <dom.Node>[];
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<dom.Node> nodes) =>
nodes.map((e) => stringify(e)).join().replaceAll('\n', '');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions lib/src/plugins/html/html_document_decoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
),
];

case HTMLTags.div:
return _parseDivElement(element);

case HTMLTags.paragraph:
return _parseParagraphElement(element);

Expand Down Expand Up @@ -423,6 +426,62 @@ class DocumentHTMLDecoder extends Converter<String, Document> {
return [paragraphNode(delta: delta), ...specialNodes];
}

/// Parse a `<div>` that may be a to-do / checkbox item:
/// `<div><input type="checkbox" [checked]/> text…</div>`
/// (the shape emitted by HTMLTodoListNodeParser). When a checkbox `<input>`
/// 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 `<div>`s from other sources are
/// unaffected).
Iterable<Node> _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 = <Node>[];
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')) {
Expand Down
32 changes: 32 additions & 0 deletions test/plugins/html/checkbox_roundtrip_test.dart
Original file line number Diff line number Diff line change
@@ -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
// <div><input type="checkbox" [checked]/>text</div>; the decoder must rebuild
// a todo_list node (previously <div> fell through to a plain paragraph and the
// <input> 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');
});
}