From 54f4f7aa889e7c586b241d440ac74e391f1c0e97 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 17 Apr 2026 17:31:44 +0200 Subject: [PATCH] Added click handling and HTML attribute link options to the editor --- .../docs/features/blocks/inline-content.mdx | 47 ++++++++++ packages/core/src/editor/BlockNoteEditor.ts | 19 ++++ .../managers/ExtensionManager/extensions.ts | 92 ++++++++++++++++++- 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/features/blocks/inline-content.mdx b/docs/content/docs/features/blocks/inline-content.mdx index ca7799d841..5b2441c198 100644 --- a/docs/content/docs/features/blocks/inline-content.mdx +++ b/docs/content/docs/features/blocks/inline-content.mdx @@ -79,6 +79,53 @@ type Link = { }; ``` +### Customizing Links + +You can customize how links are rendered and how they respond to clicks with the `links` editor option. + +```ts +const editor = new BlockNoteEditor({ + links: { + HTMLAttributes: { + class: "my-link-class", + target: "_blank", + }, + onClick: (event) => { + // Custom click logic, e.g. routing without a page reload. + }, + }, +}); +``` + +#### `HTMLAttributes` + +Additional HTML attributes that should be added to rendered link elements. + +```ts +const editor = new BlockNoteEditor({ + links: { + HTMLAttributes: { + class: "my-link-class", + target: "_blank", + }, + }, +}); +``` + +#### `onClick` + +Custom handler invoked when a link is clicked. If left `undefined`, links are opened in a new window on click (the default behavior). If provided, that default behavior is disabled and this function is called instead. + +```ts +const editor = new BlockNoteEditor({ + links: { + onClick: (event) => { + // Do something when a link is clicked. + }, + }, +}); +``` + ## Default Styles The default text formatting options in BlockNote are represented by the `Styles` in the default schema: diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 561f77b158..f9a70ded61 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -140,6 +140,25 @@ export interface BlockNoteEditorOptions< NoInfer >[]; + /** + * Options for configuring how links behave in the editor. + */ + links?: { + /** + * HTML attributes to add to rendered link elements. + * + * @default {} + * @example { class: "my-link-class", target: "_blank" } + */ + HTMLAttributes?: Record; + /** + * Custom handler invoked when a link is clicked. If left `undefined`, + * links are opened in a new window on click. If provided, the default + * open-on-click behavior is disabled and this function is called instead. + */ + onClick?: (event: MouseEvent) => void; + }; + /** * @deprecated, provide placeholders via dictionary instead * @internal diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 4364afaaa0..6ab0d528a2 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -1,12 +1,14 @@ import { AnyExtension as AnyTiptapExtension, extensions, + getAttributes, Node, Extension as TiptapExtension, } from "@tiptap/core"; import { Gapcursor } from "@tiptap/extensions/gap-cursor"; import { Link } from "@tiptap/extension-link"; import { Text } from "@tiptap/extension-text"; +import { Plugin, PluginKey } from "prosemirror-state"; import { createDropFileExtension } from "../../../api/clipboard/fromClipboard/fileDropExtension.js"; import { createPasteFromClipboardExtension } from "../../../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../../../api/clipboard/toClipboard/copyExtension.js"; @@ -91,12 +93,94 @@ export function getDefaultTiptapExtensions( delete (attrs as Record).title; return attrs; }, + addProseMirrorPlugins() { + const plugins = this.parent?.() || []; + + const markType = this.type; + const tiptapEditor = this.editor; + // Copied from @tiptap/extension-link's `clickHandler` helper, but + // calls `onClick` if the editor option is defined. Otherwise, uses + // default behaviour. + // https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/src/helpers/clickHandler.ts + plugins.push( + new Plugin({ + key: new PluginKey("linkClickHandler"), + props: { + handleClick: (view, _pos, event) => { + if (event.button !== 0) { + return false; + } + + if (!view.editable) { + return false; + } + + let link: HTMLAnchorElement | null = null; + + if (event.target instanceof HTMLAnchorElement) { + link = event.target; + } else { + const target = event.target as HTMLElement | null; + if (!target) { + return false; + } + + const root = tiptapEditor.view.dom; + + // Tntentionally limit the lookup to the editor root. + // Using tag names like DIV as boundaries breaks with custom NodeViews, + link = target.closest("a"); + + if (link && !root.contains(link)) { + link = null; + } + } + + if (!link) { + return false; + } + + let handled = false; + + // `enableClickSelection` is always disabled. + // if (options.enableClickSelection) { + // const commandResult = + // tiptapEditor.commands.extendMarkRange( + // markType.name, + // ); + // handled = commandResult; + // } + + if (options.links?.onClick) { + options.links.onClick(event); + } else { + const attrs = getAttributes(view.state, markType.name); + const href = link.href ?? attrs.href; + const target = link.target ?? attrs.target; + + if (href) { + window.open(href, target); + handled = true; + } + } + + return handled; + }, + }, + }), + ); + + return plugins; + }, }) .configure({ - defaultProtocol: DEFAULT_LINK_PROTOCOL, - // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 - protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS, - }), + defaultProtocol: DEFAULT_LINK_PROTOCOL, + // only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450 + protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS, + HTMLAttributes: options.links?.HTMLAttributes ?? {}, + // Always false as we handle clicks ourselves above. + openOnClick: false, + }), ...(Object.values(editor.schema.styleSpecs).map((styleSpec) => { return styleSpec.implementation.mark.configure({ editor: editor,