Skip to content

Commit 9a7243c

Browse files
committed
Migrate from TipTap extensions to BlockNote native extensions
BlockNote core team confirmed _tiptapOptions will be removed in a future version. Convert both editor extensions (ExternalLinkCapture, ExternalLinkA11y) from TipTap Extension.create() to BlockNote createExtension(), and register via the extensions option instead of _tiptapOptions.extensions. ProseMirror plugin code is unchanged.
1 parent 7d6e8ee commit 9a7243c

5 files changed

Lines changed: 67 additions & 73 deletions

File tree

frontend/src/react/components/OpBlockNoteEditor.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,10 @@ export function OpBlockNoteEditor({
105105
},
106106
dictionary: localeDictionary,
107107
...(attachmentsEnabled && { uploadFile }),
108-
_tiptapOptions: {
109-
extensions: [
110-
ExternalLinkA11yExtension,
111-
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
112-
],
113-
},
108+
extensions: [
109+
ExternalLinkA11yExtension,
110+
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
111+
],
114112
};
115113
}, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile, captureExternalLinks]);
116114

frontend/src/react/extensions/external-link-a11y.ts

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626
// See COPYRIGHT and LICENSE files for more details.
2727
//++
2828

29-
import { Extension } from '@tiptap/core';
30-
import { Plugin, PluginKey } from '@tiptap/pm/state';
31-
import { Decoration, DecorationSet } from '@tiptap/pm/view';
32-
import type { Node as PmNode } from '@tiptap/pm/model';
29+
import { createExtension } from '@blocknote/core';
30+
import { Plugin, PluginKey } from 'prosemirror-state';
31+
import { Decoration, DecorationSet } from 'prosemirror-view';
32+
import type { Node as PmNode } from 'prosemirror-model';
3333
import { isHrefExternal } from 'core-stimulus/helpers/external-link-helpers';
3434

3535
const pluginKey = new PluginKey('externalLinkA11y');
@@ -52,7 +52,7 @@ function buildDecorations(doc:PmNode):DecorationSet {
5252
}
5353

5454
/**
55-
* TipTap extension that adds `aria-describedby` to external links inside the
55+
* BlockNote extension that adds `aria-describedby` to external links inside the
5656
* editor via ProseMirror Decorations.
5757
*
5858
* Decorations add DOM attributes without modifying the document model, so
@@ -68,30 +68,28 @@ function buildDecorations(doc:PmNode):DecorationSet {
6868
* opens in a new tab. It lives in the main layout (`base.html.erb`) and is
6969
* cloned into the BlockNote shadow DOM by `block-note-element.ts`.
7070
*/
71-
export const ExternalLinkA11yExtension = Extension.create({
72-
name: 'externalLinkA11y',
71+
export const ExternalLinkA11yExtension = createExtension({
72+
key: 'externalLinkA11y',
7373

74-
addProseMirrorPlugins() {
75-
return [
76-
new Plugin({
77-
key: pluginKey,
78-
state: {
79-
init(_, { doc }) {
80-
return buildDecorations(doc);
81-
},
82-
apply(tr, oldDecos) {
83-
if (tr.docChanged) {
84-
return buildDecorations(tr.doc);
85-
}
86-
return oldDecos.map(tr.mapping, tr.doc);
87-
},
74+
prosemirrorPlugins: [
75+
new Plugin({
76+
key: pluginKey,
77+
state: {
78+
init(_, { doc }) {
79+
return buildDecorations(doc);
8880
},
89-
props: {
90-
decorations(state) {
91-
return pluginKey.getState(state) as DecorationSet;
92-
},
81+
apply(tr, oldDecos) {
82+
if (tr.docChanged) {
83+
return buildDecorations(tr.doc);
84+
}
85+
return oldDecos.map(tr.mapping, tr.doc);
9386
},
94-
}),
95-
];
96-
},
87+
},
88+
props: {
89+
decorations(state) {
90+
return pluginKey.getState(state) as DecorationSet;
91+
},
92+
},
93+
}),
94+
],
9795
});

frontend/src/react/extensions/external-link-capture.ts

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,56 +26,54 @@
2626
// See COPYRIGHT and LICENSE files for more details.
2727
//++
2828

29-
import { Extension } from '@tiptap/core';
30-
import { Plugin, PluginKey } from '@tiptap/pm/state';
29+
import { createExtension } from '@blocknote/core';
30+
import { Plugin, PluginKey } from 'prosemirror-state';
3131
import { buildExternalRedirectUrl, isExternalLinkCandidate, isLinkExternal } from 'core-stimulus/helpers/external-link-helpers';
3232

3333
/**
34-
* TipTap extension that intercepts clicks on external links and routes them
34+
* BlockNote extension that intercepts clicks on external links and routes them
3535
* through `/external_redirect` for phishing prevention.
3636
*
3737
* Uses ProseMirror's `handleDOMEvents.mousedown` to intercept before
3838
* ProseMirror creates its internal MouseDown tracker. Returning `true`
3939
* prevents the entire ProseMirror click chain (mousedown → MouseDown.up →
40-
* handleSingleClick → handleClick), so TipTap's Link extension never calls
40+
* handleSingleClick → handleClick), so the editor never calls
4141
* `window.open` with the original URL. Only our redirect window opens.
4242
*
4343
* This extension should only be registered when external link capture is
44-
* enabled — when disabled, TipTap's default `openOnClick: true` handles
45-
* link clicks natively.
44+
* enabled — when disabled, the editor's default link handling applies and
45+
* link clicks are handled natively.
4646
*/
47-
export const ExternalLinkCaptureExtension = Extension.create({
48-
name: 'externalLinkCapture',
47+
export const ExternalLinkCaptureExtension = createExtension({
48+
key: 'externalLinkCapture',
4949

50-
addProseMirrorPlugins() {
51-
return [
52-
new Plugin({
53-
key: new PluginKey('externalLinkCapture'),
54-
props: {
55-
handleDOMEvents: {
56-
mousedown: (view, event) => {
57-
// Left-click (0) and middle-click (1) only — right-click (2)
58-
// opens the native context menu which reads href from the DOM
59-
// and cannot be intercepted via JavaScript.
60-
if (event.button !== 0 && event.button !== 1) return false;
50+
prosemirrorPlugins: [
51+
new Plugin({
52+
key: new PluginKey('externalLinkCapture'),
53+
props: {
54+
handleDOMEvents: {
55+
mousedown: (view, event) => {
56+
// Left-click (0) and middle-click (1) only — right-click (2)
57+
// opens the native context menu which reads href from the DOM
58+
// and cannot be intercepted via JavaScript.
59+
if (event.button !== 0 && event.button !== 1) return false;
6160

62-
const target = event.target instanceof Element
63-
? event.target
64-
: (event.target as Node)?.parentElement;
65-
const link = target?.closest('a');
66-
if (!(link instanceof HTMLAnchorElement)) return false;
67-
if (!view.dom.contains(link)) return false;
68-
if (!isExternalLinkCandidate(link)) return false;
69-
if (!isLinkExternal(link)) return false;
70-
if (link.dataset.allowExternalLink) return false;
61+
const target = event.target instanceof Element
62+
? event.target
63+
: (event.target as Node)?.parentElement;
64+
const link = target?.closest('a');
65+
if (!(link instanceof HTMLAnchorElement)) return false;
66+
if (!view.dom.contains(link)) return false;
67+
if (!isExternalLinkCandidate(link)) return false;
68+
if (!isLinkExternal(link)) return false;
69+
if (link.dataset.allowExternalLink) return false;
7170

72-
event.preventDefault();
73-
window.open(buildExternalRedirectUrl(link.href), '_blank', 'noopener,noreferrer');
74-
return true;
75-
},
71+
event.preventDefault();
72+
window.open(buildExternalRedirectUrl(link.href), '_blank', 'noopener,noreferrer');
73+
return true;
7674
},
7775
},
78-
}),
79-
];
80-
},
76+
},
77+
}),
78+
],
8179
});

frontend/src/stimulus/helpers/external-link-helpers.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
/**
3030
* Shared utilities for external link handling. Used by both
3131
* ExternalLinksController (DOM rewriting for server-rendered pages) and
32-
* ProseMirrorExternalLinksController (click interception for BlockNote editors).
32+
* BlockNote editor extensions (click interception and accessibility).
3333
*/
3434

3535
/**
@@ -85,8 +85,8 @@ export function isExternalLinkCandidate(link:HTMLAnchorElement) {
8585
/**
8686
* Builds the `/external_redirect` URL that the server uses for external link
8787
* capture. The ExternalLinksController rewrites hrefs directly; the
88-
* ProseMirrorExternalLinksController passes this URL to `window.open` on click
89-
* to avoid corrupting the ProseMirror document model.
88+
* ExternalLinkCaptureExtension passes this URL to `window.open` on click
89+
* to avoid corrupting the BlockNote/ProseMirror document model.
9090
*/
9191
export function buildExternalRedirectUrl(href:string):string {
9292
const basePath = window.appBasePath ?? '';

modules/documents/spec/features/external_links_in_block_note_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
visit document_path(document)
6161
end
6262

63-
it "editor remains interactive after ProseMirrorExternalLinksController connects" do
63+
it "editor remains interactive when external-link extensions are active" do
6464
expect(page).to have_test_selector("blocknote-document-description")
6565
editor.fill_in("Hello from the editor")
6666
expect(editor.content).to include("Hello from the editor")

0 commit comments

Comments
 (0)