Skip to content

Commit 3a9a980

Browse files
committed
fix(context-menu): preserve whitespace and selection on paste
Two fixes for context menu paste: 1. Prefer view.pasteText when plain text is available — Chromium wraps writeText() output in HTML, which routes through handleHtmlPaste and strips leading/trailing spaces. Still runs URL detection via handleClipboardPaste with empty html. 2. Preserve range selection on right-click — the coordinate-based isClickInsideSelection check is unreliable in presentation mode (posAtCoords round-trip can misreport position), so keep any active range selection instead of risking collapse. 3. Add preserveWhitespace to handleHtmlPaste as defense-in-depth for HTML paste paths that don't have a plain-text fallback.
1 parent 67011d9 commit 3a9a980

2 files changed

Lines changed: 18 additions & 27 deletions

File tree

packages/super-editor/src/components/context-menu/ContextMenu.vue

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -296,21 +296,14 @@ const handleRightClick = async (event) => {
296296
297297
event.preventDefault();
298298
299-
// Update cursor position to the right-click location before opening context menu,
300-
// unless the click lands inside an active selection (keep selection intact).
299+
// Update cursor position to the right-click location before opening context menu.
300+
// When the user has a range selection, keep it — coordinate-based hit testing
301+
// in presentation mode can misreport whether the click is inside the selection,
302+
// which would collapse it unexpectedly.
301303
const editorState = props.editor?.state;
302304
const hasRangeSelection = editorState?.selection?.from !== editorState?.selection?.to;
303-
let isClickInsideSelection = false;
304305
305-
if (hasRangeSelection && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
306-
const hit = props.editor?.posAtCoords?.({ left: event.clientX, top: event.clientY });
307-
if (typeof hit?.pos === 'number') {
308-
const { from, to } = editorState.selection;
309-
isClickInsideSelection = hit.pos >= from && hit.pos <= to;
310-
}
311-
}
312-
313-
if (!isClickInsideSelection) {
306+
if (!hasRangeSelection) {
314307
moveCursorToMouseEvent(event, props.editor);
315308
}
316309

packages/super-editor/src/components/context-menu/menuItems.js

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -323,22 +323,20 @@ export function getItems(context, customItems = [], includeDefaultItems = true)
323323
view.dispatch(tr.setSelection(SelectionType.create(doc, safeFrom, safeTo)));
324324
}
325325
}
326-
const handled = handleClipboardPaste({ editor, view }, html, text);
327-
if (!handled) {
328-
const pasteEvent = createPasteEventShim({ html, text });
329-
330-
if (html && typeof view.pasteHTML === 'function') {
331-
view.pasteHTML(html, pasteEvent);
332-
return;
333-
}
334-
335-
if (text && typeof view.pasteText === 'function') {
336-
view.pasteText(text, pasteEvent);
337-
return;
326+
// When plain text is available, prefer view.pasteText — it preserves
327+
// whitespace correctly. Chromium wraps writeText() output in HTML,
328+
// which routes through handleHtmlPaste and strips leading/trailing spaces.
329+
// Still run handleClipboardPaste for URL detection (passes empty html
330+
// so it hits the 'plain-text' branch).
331+
if (text) {
332+
const urlHandled = handleClipboardPaste({ editor, view }, '', text);
333+
if (!urlHandled && typeof view.pasteText === 'function') {
334+
view.pasteText(text, createPasteEventShim({ html: '', text }));
338335
}
339-
340-
if (text && editor.commands?.insertContent) {
341-
editor.commands.insertContent(text, { contentType: 'text' });
336+
} else if (html) {
337+
const handled = handleClipboardPaste({ editor, view }, html, '');
338+
if (!handled && typeof view.pasteHTML === 'function') {
339+
view.pasteHTML(html, createPasteEventShim({ html, text: '' }));
342340
}
343341
}
344342
},

0 commit comments

Comments
 (0)