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 @@ -74,8 +74,11 @@ KeyEventResult _toggleAttribute(
node.type == HeadingBlockKeys.type &&
node.attributes[HeadingBlockKeys.level] == level;

final delta = (node.delta ?? Delta()).toJson();

// The delta is read per node inside the callback. Reading it once from
// `selection.start` and reusing it here wrote the first block's text over
// every block in a multi-block selection, destroying the rest -- and this
// shortcut path has no single-selection gate at all, so it was reachable
// with any selection. See heading_toolbar_items.dart (2026-07-27).
editorState.formatNode(
selection,
(node) => node.copyWith(
Expand All @@ -86,7 +89,7 @@ KeyEventResult _toggleAttribute(
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: delta,
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
),
);
Expand Down
46 changes: 29 additions & 17 deletions lib/src/editor/toolbar/desktop/items/heading_toolbar_items.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,40 @@ class _HeadingToolbarItem extends ToolbarItem {
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight =
node.type == 'heading' && node.attributes['level'] == level;
final delta = (node.delta ?? Delta()).toJson();
final child = SVGIconItemWidget(
iconName: 'toolbar/h$level',
isHighlight: isHighlight,
highlightColor: highlightColor,
iconColor: iconColor,
onPressed: () => editorState.formatNode(
selection,
(node) => node.copyWith(
type: isHighlight
? ParagraphBlockKeys.type
: HeadingBlockKeys.type,
attributes: {
HeadingBlockKeys.level: level,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: delta,
},
),
),
onPressed: () {
// Both the selection and each node's delta are read HERE, not
// captured when this item was built. Hoisting them out was real
// data loss: `formatNode` runs the callback for every node in
// the selection, so one block's text was written over all of
// them and the others' writing was destroyed (2026-07-27). The
// build-time capture also went stale whenever the selection
// grew while the toolbar stayed on screen.
final selection = editorState.selection;
if (selection == null) {
return;
}
editorState.formatNode(
selection,
(node) => node.copyWith(
type: isHighlight
? ParagraphBlockKeys.type
: HeadingBlockKeys.type,
attributes: {
HeadingBlockKeys.level: level,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
),
);
},
);

if (tooltipBuilder != null) {
Expand Down
36 changes: 22 additions & 14 deletions lib/src/editor/toolbar/desktop/items/paragraph_toolbar_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,33 @@ final ToolbarItem paragraphItem = ToolbarItem(
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!;
final isHighlight = node.type == 'paragraph';
final delta = (node.delta ?? Delta()).toJson();
final child = SVGIconItemWidget(
iconName: 'toolbar/text',
isHighlight: isHighlight,
highlightColor: highlightColor,
iconColor: iconColor,
onPressed: () => editorState.formatNode(
selection,
(node) => node.copyWith(
type: ParagraphBlockKeys.type,
attributes: {
blockComponentDelta: delta,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
},
),
),
// Read the selection and each node's delta at press time. See the note in
// heading_toolbar_items.dart -- hoisting the delta out of this callback
// wrote one block's text over every block in the selection.
onPressed: () {
final selection = editorState.selection;
if (selection == null) {
return;
}
editorState.formatNode(
selection,
(node) => node.copyWith(
type: ParagraphBlockKeys.type,
attributes: {
blockComponentDelta: (node.delta ?? Delta()).toJson(),
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
},
),
);
},
);

if (tooltipBuilder != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,45 @@ void main() async {
node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
});

// Regression test for real data loss on a real page, 2026-07-27.
//
// The delta was read ONCE from the block at `selection.start` and then
// written into every block the format callback ran for. Toggling a heading
// over a multi-block selection therefore stamped the first block's text
// over all the others and destroyed their writing. This shortcut path has
// no single-selection gate, so any selection could trigger it.
test('toggling a heading over several blocks keeps each blocks own text',
() {
final document = Document.blank()
..addParagraph(initialText: 'first')
..addParagraph(initialText: 'second')
..addParagraph(initialText: 'third');
final editorState = EditorState(document: document);

editorState.selection = Selection(
start: Position(path: [0]),
end: Position(path: [2], offset: 'third'.length),
);
toggleH1.execute(editorState);

final texts = editorState.document.root.children
.map((n) => n.delta?.toPlainText())
.toList();
expect(texts, ['first', 'second', 'third']);
expect(
editorState.document.root.children.map((n) => n.type),
everyElement(HeadingBlockKeys.type),
);

// ...and back again, which is the same callback with the other type.
toggleH1.execute(editorState);
expect(
editorState.document.root.children
.map((n) => n.delta?.toPlainText())
.toList(),
['first', 'second', 'third'],
);
});
});
}