diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index 67bb9f82..358dc822 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -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 = ({ 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], ); diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx index 63fd10bf..81e77f57 100644 --- a/apps/client/src/features/editor/components/link/link-menu.tsx +++ b/apps/client/src/features/editor/components/link/link-menu.tsx @@ -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); }, diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts index a9b68ec8..5357e6aa 100644 --- a/packages/editor-ext/src/lib/link.ts +++ b/packages/editor-ext/src/lib/link.ts @@ -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 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; + }, + }, + }), ]; }, });