fix: resolve keystroke input being swallowed after link in Firefox (#1922)

* fix: resolve keystroke input being swallowed after link in Firefox

In Firefox, when the cursor is at the right boundary of a link mark,
contenteditable inserts new text inside the <a> element. ProseMirror
then rejects the DOM mutation because the link mark has inclusive: false,
causing keystrokes to be silently swallowed. Unlike Chrome, Firefox also
does not fire ProseMirror's handleTextInput callback in this state.

This adds a ProseMirror plugin that intercepts printable character
keydowns at link mark boundaries and programmatically inserts the text
without the link mark, bypassing Firefox's native contenteditable
behavior entirely.

Fixes #1773

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve keystroke input being swallowed before a link in Firefox

Extend the linkBoundaryInput plugin to also handle the left boundary
of links, where the cursor is just before a link (e.g. at the start
of a line). Firefox inserts text inside the <a> element in this case
too, causing ProseMirror to reject the mutation.

Fixes #1748
This commit is contained in:
Olivier Lambert
2026-03-03 18:19:03 +01:00
committed by GitHub
parent a3c1c6cccd
commit f5d794220e
3 changed files with 65 additions and 2 deletions
@@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
@@ -20,7 +21,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
editor.chain().focus().setLink({ href: url }).run();
editor
.chain()
.focus()
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
@@ -1,5 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
@@ -37,6 +38,10 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowEdit(false);
},
+50 -1
View File
@@ -1,6 +1,6 @@
import { mergeAttributes } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link";
import { Plugin } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const LinkExtension = TiptapLink.extend({
@@ -66,6 +66,55 @@ export const LinkExtension = TiptapLink.extend({
},
},
}),
// Fix for Firefox: when the cursor is at a boundary of a link,
// Firefox's contenteditable inserts new text *inside* the <a> element.
// ProseMirror then rejects the mutation because inclusive is false,
// causing keystrokes to be silently swallowed. Firefox also does not
// fire handleTextInput in this state, so we intercept at handleKeyDown.
// This handles both:
// - right boundary: cursor just after a link (typing appends to link)
// - left boundary: cursor just before a link, e.g. at the start of a
// line (#1748), where Firefox places new text inside the link node
new Plugin({
key: new PluginKey("linkBoundaryInput"),
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// Only handle single printable characters
if (event.key.length !== 1) return false;
// Don't handle modified keys (shortcuts) or composing (IME)
if (event.ctrlKey || event.metaKey || event.altKey || event.isComposing) return false;
const { state } = view;
const linkType = state.schema.marks.link;
if (!linkType) return false;
// Don't interfere if the user has explicitly set storedMarks
if (state.storedMarks !== null) return false;
const { from, to } = state.selection;
const $from = state.doc.resolve(from);
const nodeBefore = $from.nodeBefore;
const nodeAfter = $from.nodeAfter;
const linkBefore = nodeBefore && linkType.isInSet(nodeBefore.marks);
const linkAfter = nodeAfter && linkType.isInSet(nodeAfter.marks);
// If both sides have link marks we're in the middle — don't interfere
if (linkBefore && linkAfter) return false;
// Not at any link boundary — nothing to do
if (!linkBefore && !linkAfter) return false;
// We're at a link boundary (left or right).
// Prevent native input and insert text without the link mark.
event.preventDefault();
const tr = state.tr.insertText(event.key, from, to);
tr.removeMark(from, from + event.key.length, linkType);
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});