From bd73785431024bb81b795b8e847e635cf69b74d9 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 5 Apr 2026 01:58:51 +0100 Subject: [PATCH] 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/static/js/ace2_inner.ts | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index af6e2b287..dd6075055 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -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', () => {