Skip to content

Commit a5ff04d

Browse files
authored
feat: simplify links by inlining it to BlockNote (#2623)
1 parent bb49890 commit a5ff04d

96 files changed

Lines changed: 2564 additions & 393 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ updates:
2121
- dependency-name: "@tiptap/extension-code"
2222
- dependency-name: "@tiptap/extension-horizontal-rule"
2323
- dependency-name: "@tiptap/extension-italic"
24-
- dependency-name: "@tiptap/extension-link"
24+
2525
- dependency-name: "@tiptap/extension-paragraph"
2626
- dependency-name: "@tiptap/extension-strike"
2727
- dependency-name: "@tiptap/extension-text"

docs/content/docs/features/blocks/inline-content.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,55 @@ type Link = {
7979
};
8080
```
8181

82+
### Customizing Links
83+
84+
You can customize how links are rendered and how they respond to clicks with the `links` editor option.
85+
86+
```ts
87+
const editor = BlockNoteEditor.create({
88+
links: {
89+
HTMLAttributes: {
90+
class: "my-link-class",
91+
target: "_blank",
92+
},
93+
onClick: (event) => {
94+
// Custom click logic, e.g. routing without a page reload.
95+
},
96+
},
97+
});
98+
```
99+
100+
#### `HTMLAttributes`
101+
102+
Additional HTML attributes that should be added to rendered link elements.
103+
104+
```ts
105+
const editor = BlockNoteEditor.create({
106+
links: {
107+
HTMLAttributes: {
108+
class: "my-link-class",
109+
target: "_blank",
110+
},
111+
},
112+
});
113+
```
114+
115+
#### `onClick`
116+
117+
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.
118+
119+
Returning `false` will let BlockNote run other click handlers after this one. Returning `true` or nothing (the default) marks the event as handled.
120+
121+
```ts
122+
const editor = BlockNoteEditor.create({
123+
links: {
124+
onClick: (event) => {
125+
// Do something when a link is clicked.
126+
},
127+
},
128+
});
129+
```
130+
82131
## Default Styles
83132

84133
The default text formatting options in BlockNote are represented by the `Styles` in the default schema:

docs/content/docs/react/overview.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ The `<BlockNoteView>` component is used to render the editor. It also provides a
4545

4646
### Props
4747

48-
<auto-type-table
49-
path="../../../../packages/react/src/editor/BlockNoteView.tsx"
48+
<AutoTypeTable
49+
path="../packages/react/src/editor/BlockNoteView.tsx"
5050
name="BlockNoteViewProps"
5151
/>
5252

docs/content/docs/reference/editor/overview.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ editor.pasteMarkdown("# Hello\n\nThis is **bold** text.");
113113

114114
The editor can be configured with the following options when using `BlockNoteEditor.create`:
115115

116-
<auto-type-table
117-
path="../../../../../packages/core/src/editor/BlockNoteEditor.ts"
116+
<AutoTypeTable
117+
path="../packages/core/src/editor/BlockNoteEditor.ts"
118118
name="BlockNoteEditorOptions"
119119
/>
120120

package.json

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,7 @@
3636
],
3737
"overrides": {
3838
"vitest": "4.1.2",
39-
"@vitest/runner": "4.1.2",
40-
"msw": "2.11.5",
41-
"ai": "6.0.5",
42-
"@ai-sdk/anthropic": "3.0.2",
43-
"@ai-sdk/openai": "3.0.2",
44-
"@ai-sdk/groq": "3.0.2",
45-
"@ai-sdk/google": "3.0.2",
46-
"@ai-sdk/mistral": "3.0.2",
47-
"@ai-sdk/openai-compatible": "2.0.2",
48-
"@ai-sdk/provider-utils": "4.0.2",
49-
"@ai-sdk/react": "3.0.5",
50-
"@ai-sdk/gateway": "3.0.4"
39+
"@vitest/runner": "4.1.2"
5140
}
5241
},
5342
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
@@ -71,5 +60,30 @@
7160
"start": "serve playground/dist -c ../serve.json",
7261
"test": "nx run-many --target=test",
7362
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md}\""
74-
}
63+
},
64+
"overrides": {
65+
"msw": "2.11.5",
66+
"ai": "6.0.5",
67+
"@ai-sdk/anthropic": "3.0.2",
68+
"@ai-sdk/openai": "3.0.2",
69+
"@ai-sdk/groq": "3.0.2",
70+
"@ai-sdk/google": "3.0.2",
71+
"@ai-sdk/mistral": "3.0.2",
72+
"@ai-sdk/openai-compatible": "2.0.2",
73+
"@ai-sdk/provider-utils": "4.0.2",
74+
"@ai-sdk/react": "3.0.5",
75+
"@ai-sdk/gateway": "3.0.4",
76+
"@headlessui/react": "^2.2.4",
77+
"@tiptap/core": "^3.0.0",
78+
"@tiptap/pm": "^3.0.0"
79+
},
80+
"workspaces": [
81+
"packages/*",
82+
"examples/*/*",
83+
"playground",
84+
"fumadocs",
85+
"docs",
86+
"shared",
87+
"tests"
88+
]
7589
}

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"lint": "eslint src --max-warnings 0",
8787
"test": "vitest --run",
8888
"test-watch": "vitest watch",
89-
"clean": "rimraf dist && rimraf types"
89+
"clean": "rimraf dist && rimraf types",
90+
"update-tlds": "node scripts/update-tlds.mjs"
9091
},
9192
"dependencies": {
9293
"@emoji-mart/data": "^1.2.1",
@@ -98,7 +99,6 @@
9899
"@tiptap/extension-code": "^3.13.0",
99100
"@tiptap/extension-horizontal-rule": "^3.13.0",
100101
"@tiptap/extension-italic": "^3.13.0",
101-
"@tiptap/extension-link": "^3.22.1",
102102
"@tiptap/extension-paragraph": "^3.13.0",
103103
"@tiptap/extension-strike": "^3.13.0",
104104
"@tiptap/extension-text": "^3.13.0",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Regenerate src/extensions/tiptap-extensions/Link/helpers/tlds.ts from IANA's
4+
* authoritative TLD list.
5+
*
6+
* Run with: pnpm --filter @blocknote/core update-tlds
7+
*
8+
* Encoding format ported from linkifyjs (MIT, https://github.com/nfrasser/linkifyjs):
9+
* a sorted TLD list is built into a trie, then serialized as an ASCII string
10+
* where letters descend the trie and digit runs mean "emit a word and pop N
11+
* levels back up." Shared TLD prefixes (e.g. construction/consulting/
12+
* contractors) collapse, producing a payload smaller than a flat list.
13+
*
14+
* IDN punycode entries (XN--...) are skipped: the schemeless URL regex in
15+
* linkDetector.ts requires ASCII-only TLDs, so unicode TLDs would never reach
16+
* the validation step.
17+
*/
18+
19+
import { writeFileSync } from "node:fs";
20+
import { fileURLToPath } from "node:url";
21+
import { dirname, resolve } from "node:path";
22+
23+
const TLDS_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt";
24+
25+
const __dirname = dirname(fileURLToPath(import.meta.url));
26+
const OUT_PATH = resolve(
27+
__dirname,
28+
"../src/extensions/tiptap-extensions/Link/helpers/tlds.ts",
29+
);
30+
31+
function createTrie(words) {
32+
const root = {};
33+
for (const word of words) {
34+
let current = root;
35+
for (const letter of word) {
36+
if (!(letter in current)) {
37+
current[letter] = {};
38+
}
39+
current = current[letter];
40+
}
41+
current.isWord = true;
42+
}
43+
return root;
44+
}
45+
46+
function encodeTrieHelper(trie) {
47+
const output = [];
48+
for (const k in trie) {
49+
if (k === "isWord") {
50+
output.push(0);
51+
continue;
52+
}
53+
output.push(k);
54+
output.push(...encodeTrieHelper(trie[k]));
55+
if (typeof output[output.length - 1] === "number") {
56+
output[output.length - 1] += 1;
57+
} else {
58+
output.push(1);
59+
}
60+
}
61+
return output;
62+
}
63+
64+
function encodeTlds(tlds) {
65+
return encodeTrieHelper(createTrie(tlds)).join("");
66+
}
67+
68+
function decodeTlds(encoded) {
69+
const words = [];
70+
const stack = [];
71+
let i = 0;
72+
const digits = "0123456789";
73+
while (i < encoded.length) {
74+
let popDigitCount = 0;
75+
while (digits.indexOf(encoded[i + popDigitCount]) >= 0) {
76+
popDigitCount++;
77+
}
78+
if (popDigitCount > 0) {
79+
words.push(stack.join(""));
80+
let popCount = parseInt(encoded.substring(i, i + popDigitCount), 10);
81+
while (popCount-- > 0) {
82+
stack.pop();
83+
}
84+
i += popDigitCount;
85+
} else {
86+
stack.push(encoded[i]);
87+
i++;
88+
}
89+
}
90+
return words;
91+
}
92+
93+
async function main() {
94+
console.log(`Fetching ${TLDS_URL}...`);
95+
const response = await fetch(TLDS_URL);
96+
if (!response.ok) {
97+
throw new Error(`Failed to fetch IANA TLDs: ${response.status}`);
98+
}
99+
const body = await response.text();
100+
101+
const tlds = body
102+
.split("\n")
103+
.map((line) => line.trim())
104+
.filter((line) => line && !line.startsWith("#") && !/^XN--/i.test(line))
105+
.map((line) => line.toLowerCase())
106+
.sort();
107+
108+
console.log(`Encoding ${tlds.length} TLDs...`);
109+
const encoded = encodeTlds(tlds);
110+
111+
console.log("Round-trip asserting...");
112+
const decoded = decodeTlds(encoded);
113+
if (JSON.stringify(decoded) !== JSON.stringify(tlds)) {
114+
throw new Error("Encode/decode round-trip mismatch");
115+
}
116+
117+
const fileContents = `// THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.
118+
// Source: ${TLDS_URL}
119+
// Regenerate with: pnpm --filter @blocknote/core update-tlds
120+
// Encoding format ported from linkifyjs (MIT) — trie collapsed into ASCII.
121+
122+
export const ENCODED_TLDS =
123+
"${encoded}";
124+
`;
125+
126+
writeFileSync(OUT_PATH, fileContents);
127+
console.log(
128+
`Wrote ${OUT_PATH} (${encoded.length} chars, ${tlds.length} TLDs)`,
129+
);
130+
}
131+
132+
main().catch((err) => {
133+
console.error(err);
134+
process.exit(1);
135+
});

packages/core/src/editor/BlockNoteEditor.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,54 @@ export interface BlockNoteEditorOptions<
140140
NoInfer<SSchema>
141141
>[];
142142

143+
/**
144+
* Options for configuring how links behave in the editor.
145+
*/
146+
links?: {
147+
/**
148+
* HTML attributes to add to rendered link elements.
149+
*
150+
* @default {}
151+
* @example { class: "my-link-class", target: "_blank" }
152+
*/
153+
HTMLAttributes?: Record<string, any>;
154+
/**
155+
* Custom handler invoked when a link is clicked. If left `undefined`,
156+
* links are opened in a new window on click. If provided, the default
157+
* open-on-click behavior is disabled and this function is called instead.
158+
*
159+
* Return `false` to let ProseMirror continue handling the click event.
160+
* Returning `true` or nothing (the default) marks the event as handled.
161+
*/
162+
onClick?: (
163+
event: MouseEvent,
164+
editor: BlockNoteEditor<any, any, any>,
165+
) => boolean | void;
166+
/**
167+
* Callback that decides whether a given `href` is a valid link. Applied at
168+
* every gate where a link enters the document: HTML import, HTML export,
169+
* paste, and autolink. Useful for supporting additional URI schemes (e.g.
170+
* `vscode:`, `myapp:`) or tightening the default allowlist.
171+
*
172+
* Defaults to `isAllowedUri`, which allows
173+
* `http|https|ftp|ftps|mailto|tel|callto|sms|cid|xmpp`. Import
174+
* `isAllowedUri` from `@blocknote/core` to layer on top of the default.
175+
*
176+
* @example
177+
* ```ts
178+
* import { isAllowedUri } from "@blocknote/core";
179+
*
180+
* BlockNoteEditor.create({
181+
* links: {
182+
* isValidLink: (href) =>
183+
* isAllowedUri(href) || href.startsWith("myapp:"),
184+
* },
185+
* });
186+
* ```
187+
*/
188+
isValidLink?: (href: string) => boolean;
189+
};
190+
143191
/**
144192
* @deprecated, provide placeholders via dictionary instead
145193
* @internal
@@ -1135,6 +1183,32 @@ export class BlockNoteEditor<
11351183
this._styleManager.createLink(url, text);
11361184
}
11371185

1186+
/**
1187+
* Find the link mark and its range at the given position.
1188+
* Returns undefined if there is no link at that position.
1189+
*/
1190+
public getLinkMarkAtPos(pos: number) {
1191+
return this._styleManager.getLinkMarkAtPos(pos);
1192+
}
1193+
1194+
/**
1195+
* Updates the link at the given position with a new URL and text.
1196+
* @param url The new link URL.
1197+
* @param text The new text to display.
1198+
* @param position The position inside the link to edit. Defaults to the current selection anchor.
1199+
*/
1200+
public editLink(url: string, text: string, position?: number) {
1201+
this._styleManager.editLink(url, text, position);
1202+
}
1203+
1204+
/**
1205+
* Removes the link at the given position, keeping the text.
1206+
* @param position The position inside the link to remove. Defaults to the current selection anchor.
1207+
*/
1208+
public deleteLink(position?: number) {
1209+
this._styleManager.deleteLink(position);
1210+
}
1211+
11381212
/**
11391213
* Checks if the block containing the text cursor can be nested.
11401214
*/

0 commit comments

Comments
 (0)