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 { NodeSelection } from '@tiptap/pm/state';
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
export interface BaseEmbedOptions {
HTMLAttributes: Record<string, any>;
@@ -62,11 +62,11 @@ export const BaseEmbed = Node.create<BaseEmbedOptions>({
addKeyboardShortcuts() {
// Block Backspace / Delete when the base embed itself is the
// current selection — that's the "click on the embed and hit
// delete" accidental-delete path. Returning true tells TipTap
// we've handled the key, preventing the default removal.
// Other deletion paths (range selections covering the node,
// programmatic transactions) still go through.
// current selection — the "click on the embed and hit delete"
// accidental-delete path. Returning true tells TipTap we've
// handled the key, preventing the default removal. Range
// selections covering the node and programmatic deletes still
// work normally.
const isThisNodeSelected = (): boolean => {
const { selection } = this.editor.state;
return (
@@ -79,4 +79,28 @@ export const BaseEmbed = Node.create<BaseEmbedOptions>({
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),
},
}),
];
},
});