Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/content/docs/features/blocks/inline-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
},
},
});
```
Comment on lines +86 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docs examples use new BlockNoteEditor(...), but the constructor is protected.

In packages/core/src/editor/BlockNoteEditor.ts, the constructor is declared protected and the public entry point is the static BlockNoteEditor.create(...). All three snippets in this section (lines 86–98, 104–113, 119–127) invoke new BlockNoteEditor(...), which is not a valid public API and will fail type-checking for users copy-pasting these examples. Consider switching to BlockNoteEditor.create({ ... }) to match the rest of the documentation.

📝 Proposed fix (apply to all three examples)
-const editor = new BlockNoteEditor({
+const editor = BlockNoteEditor.create({
   links: {
     ...
   },
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```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.
},
},
});
```
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/features/blocks/inline-content.mdx` around lines 86 - 98,
The docs show using the protected constructor (new BlockNoteEditor(...)) which
is not public; update the three examples to call the public factory instead by
replacing new BlockNoteEditor({...}) with BlockNoteEditor.create({...}) so they
match the editor's public API (constructor is protected in BlockNoteEditor.ts)
and will type-check for users.


#### `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:
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,25 @@ export interface BlockNoteEditorOptions<
NoInfer<SSchema>
>[];

/**
* 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<string, any>;
/**
* 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
Expand Down
92 changes: 88 additions & 4 deletions packages/core/src/editor/managers/ExtensionManager/extensions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -91,12 +93,94 @@ export function getDefaultTiptapExtensions(
delete (attrs as Record<string, unknown>).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,
Comment on lines +130 to +131
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo: "Tntentionally" → "Intentionally", and the comment sentence is truncated ("Using tag names like DIV as boundaries breaks with custom NodeViews,").

-                    // Tntentionally limit the lookup to the editor root.
-                    // Using tag names like DIV as boundaries breaks with custom NodeViews,
+                    // Intentionally limit the lookup to the editor root.
+                    // Using tag names like DIV as boundaries breaks with custom NodeViews.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Tntentionally limit the lookup to the editor root.
// Using tag names like DIV as boundaries breaks with custom NodeViews,
// Intentionally limit the lookup to the editor root.
// Using tag names like DIV as boundaries breaks with custom NodeViews.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 130 - 131, Fix the typo and complete the truncated comment in
extensions.ts: change "Tntentionally" to "Intentionally" and finish the sentence
so it reads clearly (e.g., "Intentionally limit the lookup to the editor root.
Using tag names like DIV as boundaries breaks with custom NodeViews, so we must
scope to the editor root to avoid incorrect boundary detection."). Update the
comment near the ExtensionManager or extensions.ts lookup logic (the block that
mentions editor root and NodeViews) so it is grammatically correct and conveys
the full rationale.

link = target.closest<HTMLAnchorElement>("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;
Comment on lines +161 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

window.open(href, target) without noopener,noreferrer is a tabnabbing risk.

Opening a user-controlled URL in a new browsing context without noopener leaves window.opener accessible to the destination page, which can then navigate the original editor tab via window.opener.location. Since link href/target come from editor content (often user-authored), this should use "noopener,noreferrer" features by default:

-                      window.open(href, target);
+                      window.open(href, linkTarget || "_blank", "noopener,noreferrer");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (href) {
window.open(href, target);
handled = true;
if (href) {
window.open(href, target, "noopener,noreferrer");
handled = true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 161 - 163, The current call window.open(href, target) is vulnerable to
tabnabbing; update the logic where href/target are used (the branch that sets
handled = true) to call window.open with the feature string
"noopener,noreferrer" (e.g., window.open(href, target, "noopener,noreferrer"))
so the opened page cannot access window.opener; ensure this is applied whenever
opening external links from the editor (the code path using href, target,
handled) and keep handled = true unchanged.

}
Comment on lines +158 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

HTMLAnchorElement href and target IDL attribute return type when attribute absent

💡 Result:

When the href attribute is absent on an HTMLAnchorElement, the href IDL attribute returns the empty string (""), as explicitly stated in MDN documentation with examples showing a newly created <a> element returns "". This differs from setting href to an empty string, which resolves to the document's base URL. When the target attribute is absent, the target IDL attribute returns the empty string (""), as both are reflected DOMString attributes per the HTML specification's reflection rules: the getter returns the attribute's value or null/empty string if absent, and MDN confirms it "reflects the target attribute" with no default value specified.

Citations:


🏁 Script executed:

find . -type f -name "extensions.ts" | grep -i extensionmanager

Repository: TypeCellOS/BlockNote

Length of output: 130


🏁 Script executed:

cat -n ./packages/core/src/editor/managers/ExtensionManager/extensions.ts | sed -n '110,180p'

Repository: TypeCellOS/BlockNote

Length of output: 2993


🏁 Script executed:

cat -n ./packages/core/src/editor/managers/ExtensionManager/extensions.ts | sed -n '1,50p'

Repository: TypeCellOS/BlockNote

Length of output: 2019


🏁 Script executed:

cat -n ./packages/core/src/editor/managers/ExtensionManager/extensions.ts | sed -n '100,175p'

Repository: TypeCellOS/BlockNote

Length of output: 3229


link.href ?? attrs.href and link.target ?? attrs.target never fall back.

HTMLAnchorElement.href and HTMLAnchorElement.target are IDL attributes that always return a string — they are never null/undefined. For an anchor with no href attribute, link.href is "", and for no target, link.target is "". Because nullish coalescing only falls back on null/undefined, the attrs.href / attrs.target paths are effectively dead code, and when the rendered anchor has an empty target, you'll pass "" to window.open instead of the mark's configured target.

Additionally, when options.links.onClick is provided (line 154), handled is never set to true, so the handler returns false and ProseMirror doesn't claim the click, allowing default browser navigation to proceed even though the callback was fired. The callback should either set handled = true or call event.preventDefault().

Also fix the typo at line 130: "Tntentionally" → "Intentionally".

Use getAttribute(...) (which returns null when missing and round-trips through ??), rename target to avoid confusion with the DOM element property, and set handled = true when onClick is invoked:

🐛 Proposed fix
                  if (options.links?.onClick) {
                    options.links.onClick(event);
+                   handled = true;
                  } else {
                    const attrs = getAttributes(view.state, markType.name);
-                   const href = link.href ?? attrs.href;
-                   const target = link.target ?? attrs.target;
+                   const href = link.getAttribute("href") || attrs.href;
+                   const linkTarget =
+                     link.getAttribute("target") || attrs.target;

                    if (href) {
-                     window.open(href, target);
+                     window.open(href, linkTarget);
                      handled = true;
                    }
                  }

-                 // Tntentionally limit the lookup to the editor root.
+                 // Intentionally limit the lookup to the editor root.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const href = link.href ?? attrs.href;
const target = link.target ?? attrs.target;
if (href) {
window.open(href, target);
handled = true;
}
const href = link.getAttribute("href") || attrs.href;
const linkTarget =
link.getAttribute("target") || attrs.target;
if (href) {
window.open(href, linkTarget);
handled = true;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 158 - 164, Replace uses of the DOM IDL properties so fallback values work:
read href and target via link.getAttribute('href') and
link.getAttribute('target') (e.g., const hrefAttr = link.getAttribute('href') ??
attrs.href; const linkTarget = link.getAttribute('target') ?? attrs.target)
instead of link.href / link.target; rename the local target variable to avoid
shadowing the DOM property (e.g., linkTarget) and pass that to window.open;
ensure that when options.links.onClick is called you set handled = true (or call
event.preventDefault()) so ProseMirror doesn't allow default navigation; also
correct the typo "Tntentionally" to "Intentionally" in the surrounding
comment/strings.

}

return handled;
},
Comment on lines +143 to +168
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

onClick branch returns handled = false, so the browser may still navigate.

When options.links?.onClick is provided, handled stays false, and the plugin returns false from handleClick. ProseMirror then does not treat the click as consumed, and the underlying DOM click event continues to propagate — for a real <a href="…">, the browser's default navigation will fire after your custom handler, which typically defeats the whole point of overriding onClick (e.g. SPA routing) and can cause a full page reload.

Either return true (and/or call event.preventDefault()) when onClick is invoked, or document that consumers must call preventDefault() themselves. The former matches the documented behavior ("default behavior is disabled and this function is called instead"):

🐛 Proposed fix
-                  if (options.links?.onClick) {
-                    options.links.onClick(event);
+                  if (options.links?.onClick) {
+                    options.links.onClick(event);
+                    handled = true;
                   } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 143 - 168, The handleClick branch that calls options.links?.onClick
currently leaves handled as false so the DOM click may still trigger navigation;
update the onClick branch in the handleClick implementation to prevent default
browser navigation and mark the click as handled — e.g., call
event.preventDefault() (and optionally event.stopPropagation()) and set handled
= true after invoking options.links.onClick(event) so the function returns true
and the click is consumed; keep the existing fallback behavior (using
getAttributes/href/window.open) unchanged.

},
}),
);

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,
Expand Down
Loading