feat(editor-ext): block typing/paste from replacing a selected base embed

Add a ProseMirror plugin with handleTextInput and handlePaste that
return true (handled, no-op) when the current selection is a
NodeSelection of the base embed. Pairs with the existing Backspace/
Delete keyboard guard — between them the two accidental-deletion
paths (focus + delete key, focus + type a character) are blocked.

Other deletion routes still work: range selections covering the
node, programmatic deletes, and so on. Pressing an arrow key
deselects the node so the user can type elsewhere.
This commit is contained in:
Philipinho
2026-04-27 04:53:36 +01:00
parent 144838aa89
commit 361afc2426
@@ -1,5 +1,5 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state'; import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
export interface BaseEmbedOptions { export interface BaseEmbedOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -62,11 +62,11 @@ export const BaseEmbed = Node.create<BaseEmbedOptions>({
addKeyboardShortcuts() { addKeyboardShortcuts() {
// Block Backspace / Delete when the base embed itself is the // Block Backspace / Delete when the base embed itself is the
// current selection — that's the "click on the embed and hit // current selection — the "click on the embed and hit delete"
// delete" accidental-delete path. Returning true tells TipTap // accidental-delete path. Returning true tells TipTap we've
// we've handled the key, preventing the default removal. // handled the key, preventing the default removal. Range
// Other deletion paths (range selections covering the node, // selections covering the node and programmatic deletes still
// programmatic transactions) still go through. // work normally.
const isThisNodeSelected = (): boolean => { const isThisNodeSelected = (): boolean => {
const { selection } = this.editor.state; const { selection } = this.editor.state;
return ( return (
@@ -79,4 +79,28 @@ export const BaseEmbed = Node.create<BaseEmbedOptions>({
Delete: () => isThisNodeSelected(), Delete: () => isThisNodeSelected(),
}; };
}, },
addProseMirrorPlugins() {
// Same idea as the Backspace/Delete shortcuts above, but for the
// other accidental-delete path: when the embed is the selection,
// a typed character or paste would replace the whole node. These
// hooks return true (handled, no-op) so the node stays put. The
// user can still press an arrow key to deselect and then type.
const nodeName = this.name;
const isThisNodeSelected = (state: EditorState): boolean => {
const { selection } = state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === nodeName
);
};
return [
new Plugin({
props: {
handleTextInput: (view) => isThisNodeSelected(view.state),
handlePaste: (view) => isThisNodeSelected(view.state),
},
}),
];
},
}); });