fix: preserve line attributes on neighboring lines during drag-and-drop (#7461)

* fix: preserve line attributes on neighboring lines during drag-and-drop

On Chrome and Safari, when dragging a line in a list, the browser's
contentEditable engine merges the removed line with its neighbor,
corrupting the neighbor's line attributes (e.g., changing its list
type).

The drop handler now captures line attributes of the lines adjacent
to the dragged content before the browser processes the drop. After
incorporateUserChanges runs, it checks if those attributes were
corrupted and restores them.

Note: this bug cannot be reproduced in Playwright's headless browsers
(DnD in contentEditable iframes isn't supported), so manual testing
with Chrome/Safari is required.

Fixes #3120

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: also save/restore lines with no list type during DnD

Lines with no list attribute can get corrupted to inherit the dragged
line's list type. Now saves all adjacent lines (including those with
no list type) and properly removes corrupted attributes when restoring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: null-safety for atKey in drop handler

Guard against atKey returning null for dynamically inserted nodes
that aren't in the rep.lines index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-05 01:58:51 +01:00 committed by GitHub
parent 72dc94f1b9
commit bd73785431
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3337,6 +3337,11 @@ function Ace2Inner(editorInfo, cssManagers) {
// in order to make content be observed by incorporateUserChanges() (see
// observeSuspiciousNodes() for more info)
const selection = getSelection();
// Capture line attributes of neighboring lines before the browser processes
// the drop. Chrome/Safari can corrupt these attributes when merging lines
// after removing the dragged content.
// See https://github.com/ether/etherpad-lite/issues/3120
let savedLineAttrs: {lineNum: number, listType: string}[] | null = null;
if (selection) {
const firstLineSelected = topLevel(selection.startPoint.node);
const lastLineSelected = topLevel(selection.endPoint.node);
@ -3346,6 +3351,32 @@ function Ace2Inner(editorInfo, cssManagers) {
const neighbor = lineBeforeSelection || lineAfterSelection;
neighbor.appendChild(targetDoc.createElement('style'));
// Save attributes of lines adjacent to the dragged content
savedLineAttrs = [];
const startEntry = firstLineSelected.id && rep.lines.atKey(firstLineSelected.id);
const endEntry = lastLineSelected.id && rep.lines.atKey(lastLineSelected.id);
if (!startEntry || !endEntry) {
// Can't determine line numbers — skip attribute saving
savedLineAttrs = null;
}
const startLine = startEntry ? rep.lines.indexOfEntry(startEntry) : -1;
const endLine = endEntry ? rep.lines.indexOfEntry(endEntry) : -1;
// Save attributes of lines adjacent to the selection, including lines
// with NO list type (empty string). A line with no list type can get
// corrupted to inherit the dragged line's type during browser merging.
if (savedLineAttrs && endLine >= 0 && endLine + 1 < rep.lines.length()) {
savedLineAttrs.push({
lineNum: endLine + 1,
listType: getLineListType(endLine + 1) || '',
});
}
if (savedLineAttrs && startLine > 0) {
savedLineAttrs.push({
lineNum: startLine - 1,
listType: getLineListType(startLine - 1) || '',
});
}
}
// Call drop hook
@ -3355,6 +3386,32 @@ function Ace2Inner(editorInfo, cssManagers) {
documentAttributeManager,
e,
});
// After the browser processes the drop and incorporateUserChanges runs,
// restore any corrupted line attributes.
if (savedLineAttrs && savedLineAttrs.length > 0) {
scheduler.setTimeout(() => {
inCallStackIfNecessary('dropRestore', () => {
incorporateUserChanges();
// Check if any saved line attributes were corrupted
for (const {lineNum, listType} of savedLineAttrs!) {
if (lineNum < rep.lines.length()) {
const currentType = getLineListType(lineNum) || '';
if (currentType !== listType) {
if (listType) {
// Restore the original list attribute
documentAttributeManager.setAttributeOnLine(lineNum, 'list', listType);
} else {
// Line should have no list type — remove the corrupted one
documentAttributeManager.removeAttributeOnLine(lineNum, 'list');
documentAttributeManager.removeAttributeOnLine(lineNum, 'start');
}
}
}
}
});
}, 100);
}
});
$(targetDoc.documentElement).on('compositionstart', () => {