From b85ff56eafe30b2efaedb15eefd45eaf3f9fae6c Mon Sep 17 00:00:00 2001 From: Matt Frank Date: Thu, 19 Feb 2026 18:36:27 -0600 Subject: [PATCH] Simplify paste handler by delegating to ProseMirror defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move slice transformations (table normalization, title→section conversion) to transformPasted where they belong. Remove custom block insertion logic that was incorrectly inserting content after the current block instead of at cursor position. ProseMirror's default handling uses slice openStart/ openEnd metadata to merge content correctly. Co-Authored-By: Claude Opus 4.5 --- src/editor/editor.ts | 165 +++++++------------------------------------ 1 file changed, 27 insertions(+), 138 deletions(-) diff --git a/src/editor/editor.ts b/src/editor/editor.ts index 504f53b..65ccbac 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -262,56 +262,6 @@ const changeListeners = new WeakMap void>(); // Selection change listeners per view const selectionListeners = new WeakMap void>(); -/** - * Insert a slice into the document, handling block vs inline content. - * Used after transforming images in pasted HTML. - */ -function insertSlice(view: EditorView, slice: Slice): void { - const { state } = view; - - // Check if slice contains block-level nodes - let hasBlockContent = false; - slice.content.forEach((node) => { - if (node.isBlock) hasBlockContent = true; - }); - - if (!hasBlockContent) { - // Just insert inline content - const tr = state.tr; - tr.replaceSelection(slice); - view.dispatch(tr); - return; - } - - // Transform title nodes to section level 1 for body paste - const transformedNodes: Node[] = []; - slice.content.forEach((node) => { - if (node.type.name === "title") { - transformedNodes.push( - schema.nodes.section.create({ level: 1 }, node.content), - ); - } else { - transformedNodes.push(node); - } - }); - - const tr = state.tr; - const { $from } = state.selection; - const currentBlock = $from.node(1); - const beforeBlock = $from.before(1); - const afterBlock = $from.after(1); - - if (currentBlock.content.size === 0) { - tr.replaceWith(beforeBlock, afterBlock, transformedNodes); - } else { - tr.insert(afterBlock, transformedNodes); - } - - const insertEnd = tr.mapping.map(afterBlock); - tr.setSelection(Selection.near(tr.doc.resolve(insertEnd))); - view.dispatch(tr); -} - export function mountEditor(host: HTMLElement): EditorView { // Create initial document with current timestamp const doc = schema.nodes.doc.create(null, [ @@ -341,6 +291,30 @@ export function mountEditor(host: HTMLElement): EditorView { if (listener) listener(); } }, + transformPasted(slice) { + // Normalize tables to enforce GFM semantics + slice = normalizeTablesInSlice(slice, schema); + + // Convert title nodes to section level 1 (e.g.,

from web pages) + let needsTransform = false; + slice.content.forEach((node) => { + if (node.type.name === "title") needsTransform = true; + }); + + if (needsTransform) { + const nodes: Node[] = []; + slice.content.forEach((node) => { + if (node.type.name === "title") { + nodes.push(schema.nodes.section.create({ level: 1 }, node.content)); + } else { + nodes.push(node); + } + }); + slice = new Slice(Fragment.from(nodes), slice.openStart, slice.openEnd); + } + + return slice; + }, handlePaste(view, event, slice) { const manager = getImageManager(view); const files = event.clipboardData?.files; @@ -428,10 +402,6 @@ export function mountEditor(host: HTMLElement): EditorView { return true; } - // Normalize tables in pasted content to enforce GFM semantics - // (flatten spanning cells, ensure first row is header cells) - slice = normalizeTablesInSlice(slice, schema); - // Check for images in HTML content that need processing const imagesToProcess = findImagesToProcess(slice); if (imagesToProcess.length > 0 && manager) { @@ -484,95 +454,14 @@ export function mountEditor(host: HTMLElement): EditorView { // Wait for fast images, then insert transformed slice Promise.all(fastPromises).then(() => { const transformedSlice = transformSliceImages(slice, srcMap); - insertSlice(view, transformedSlice); + view.dispatch(view.state.tr.replaceSelection(transformedSlice)); }); return true; // Consume the paste event } - // Check if slice contains block-level nodes - let hasBlockContent = false; - slice.content.forEach((node) => { - if (node.isBlock) hasBlockContent = true; - }); - - if (!hasBlockContent) { - return false; // Inline content - use default handling - } - - const { state, dispatch } = view; - const { $from } = state.selection; - const docIndex = $from.index(0); - - // Pasting into title - extract inline content only, filtering out images - if (docIndex === 0) { - // Collect all inline content from pasted blocks, excluding images - const inlineNodes: Node[] = []; - slice.content.forEach((node) => { - if (node.isBlock) { - // Extract inline content from blocks - node.content.forEach((child) => { - if (child.type.name !== "image") { - inlineNodes.push(child); - } - }); - } else if (node.type.name !== "image") { - inlineNodes.push(node); - } - }); - - if (inlineNodes.length === 0) { - return false; - } - - const tr = state.tr; - tr.replaceSelection(new Slice(Fragment.from(inlineNodes), 0, 0)); - dispatch(tr); - return true; - } - - // In created node - don't allow paste - if (docIndex === 1) { - return true; // Consume the event but do nothing - } - - // Pasting into body - transform title nodes to section level 1 - const transformedNodes: Node[] = []; - slice.content.forEach((node) => { - if (node.type.name === "title") { - // Convert to section level 1, preserving inline content - transformedNodes.push( - schema.nodes.section.create({ level: 1 }, node.content), - ); - } else { - transformedNodes.push(node); - } - }); - - // Only handle collapsed selection for now - if (!state.selection.empty) { - return false; - } - - const tr = state.tr; - const currentBlock = $from.node(1); - const beforeBlock = $from.before(1); - const afterBlock = $from.after(1); - - if (currentBlock.content.size === 0) { - // Empty block - replace it with pasted content - tr.replaceWith(beforeBlock, afterBlock, transformedNodes); - } else { - // Non-empty block - insert pasted content after it - tr.insert(afterBlock, transformedNodes); - } - - // Move cursor to end of inserted content - const insertEnd = tr.mapping.map(afterBlock); - tr.setSelection(Selection.near(tr.doc.resolve(insertEnd))); - - dispatch(tr); - return true; + // Let ProseMirror handle all other cases + return false; }, }); return view;