Files
docmost/apps/client/src/features/editor/components/link/link-menu.tsx
T
Olivier Lambert f5d794220e 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
2026-03-03 17:19:03 +00:00

104 lines
2.5 KiB
TypeScript

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";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
const shouldShow = useCallback(() => {
return editor.isActive("link");
}, [editor]);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onSetLink = useCallback(
(url: string) => {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowEdit(false);
},
[editor],
);
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEdit(false);
return null;
}, [editor]);
const onShowEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onHideEdit = useCallback(() => {
setShowEdit(false);
}, []);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
updateDelay={0}
options={{
onHide: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
}}
shouldShow={shouldShow}
>
{showEdit ? (
<Card
withBorder
radius="md"
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>
)}
</BaseBubbleMenu>
);
}
export default LinkMenu;