Trying to prevent nested table paste via `transformPasted`

Dear all,

I’m trying to prevent pasting tables into a table cell via transformPasted, but apparently I’m missing something. That’s my test code:

transformPasted: (slice, view) => {
	if (!this.editor.isActive(this.type.name)) {
		return slice
	}

	const { schema } = view.state

	let tablePaste = false
	slice.content.descendants((node) => {
		if (node.type.name === 'table') {
			tablePaste = true
		}
	})

	if (!tablePaste) {
		return slice
	}

	const textNode = schema.text('Nested tables are forbidden')
	const fragment = Fragment.from(schema.nodes.paragraph.create(null, textNode))
	const newSlice = new Slice(fragment, slice.openStart, slice.openEnd)
	return newSlice
}

The detection seems to work as expected. When I step through with the debugger, tablePaste gets set to true and newSlice gets compiled. But still, when I paste a table into a table cell, original clipboard content with the table ends up in the cell, not my new paragraph with text node.

Am I missing something? Could this be a race condition with some other paste handler taking precedence? I don’t understand why the code gets triggered when I place a breakpoint in it but it doesn’t seem to have any effect :thinking:

That’s very odd. Can you reduce this to a small script so that I can debug it in isolation? (Or sometimes just making something happen in a minimal context already shows where the problem lies.)

1 Like

Thanks @marijn! I put together a quick a codesandbox example: https://codesandbox.io/p/devbox/cocky-snow-9f2pnk?workspaceId=ws_KJTzr2vVN6caQzpyryFdn1

Playing around with it a bit more, it seems like passing slice.openStart and slice.openEnd to new Slice() is the culprit. If I pass 0 for both instead, it seems to work as expected.


My original code (with openStart and openEnd) came from an attempt to just strip table nodes from the given slice and replace them with a paragraph containing the textContent of the original table node.

My understanding is that slice.content.descendants() will not work here as I need to traverse the whole node tree of the slice and build a new one from it, right? So what I did was recursively running traversing the nodes and their children via forEach() the following way:

const filterFragment = (fragment) => {
	const nodes = []
	fragment.forEach((node) => {
		if (node.isText) {
			list.push(node)
			return
		}
		if (node.type.name === 'table') {
			console.debug('transformPasted table node', node)
			const textNode = node.textContent
				? schema.text(node.textContent)
				: null
			nodes.push(schema.nodes.paragraph.create(null, textNode))
			return
		}
		nodes.push(node.copy(filterFragment(node.content)))
	})

	return Fragment.from(nodes)
}

let tablePaste = false
slice.content.descendants((node) => {
	if (node.type.name === 'table') {
		tablePaste = true
	}
})

if (!tablePaste) {
	return slice
}

const newSlice = new Slice(filterFragment(slice.content), slice.openStart, slice.openEnd)
return newSlice

Stepping through with the debugger looked promising, but I guess then here the problem might as well have been that slice.openStart and slice.openEnd were wrong. Is there a smart way to calculate the correct openStart and openEnd values for the new Slice object given the compiled fragment?

I cannot access that sandbox link.

But did you see an error in the console when the problem happened? If you crash the editor’s own paste handling, you’ll get the browser’s default behavior, which would explain how you’d end up with the original content even after filtering.

1 Like

I cannot access that sandbox link.

Sorry, that should be fixed now.

But did you see an error in the console when the problem happened?

Yes, indeed at least in the sandbox I see Uncaught RangeError: Index 0 out of range for <>.

I have a working implementation now that prevents nested tables and wanted to share it here. I would be curious what others think and if you see room for improvement:

new Plugin({
	key: new PluginKey('preventNestedTables'),
	// Prevent nested table (low level protection): filter out transactions that lead to nested table
	filterTransaction: (transaction) => {
		if (!transaction.docChanged) {
			return true
		}

		let hasNestedTable = false
		transaction.doc.descendants((node, pos) => {
			if (node.type.name.startsWith('table')) {
				const $pos = transaction.doc.resolve(pos)
				for (let depth = $pos.depth; depth >= 0; depth--) {
					const ancestor = $pos.node(depth)
					if (ancestor.type.name === 'tableCell') {
						console.warn(
							'Detected nested table, filtering out transaction',
						)
						hasNestedTable = true
					}
				}
			}
		})

		return !hasNestedTable
	},
	props: {
		// Prevent nested table when pasting to a table cell
		transformPasted: (slice) => {
			if (!this.editor.isActive(this.type.name)) {
				return slice
			}

			let tablePaste = false
			slice.content.descendants((node) => {
				if (node.type.name.startsWith('table')) {
					tablePaste = true
				}
			})

			if (!tablePaste) {
				return slice
			}

			if (
				slice.content.childCount === 1
				&& slice.content.firstChild?.type.name === 'table'
			) {
				const tableChild = slice.content.firstChild.firstChild
				if (
					(tableChild.childCount === 1
						&& tableChild.type.name === 'tableRow')
					|| tableChild.type.name === 'tableHeadRow'
				) {
					const rowChild = tableChild.firstChild
					if (
						(rowChild.childCount === 1
							&& rowChild.type.name === 'tableCell')
						|| rowChild.type.name === 'tableHeader'
					) {
						return new Slice(rowChild.content, 0, 0)
					}
				}
			}

			console.warn('Nested tables are not supported')
			alert(
				t(
					'text',
					'A table was pasted into a table. Nested tables are not supported.',
				),
			)

			const newSlice = new Slice(Fragment.empty, 0, 0)
			return newSlice
		},
	},
})