Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ updates:
- dependency-name: "@tiptap/extension-code"
- dependency-name: "@tiptap/extension-horizontal-rule"
- dependency-name: "@tiptap/extension-italic"
- dependency-name: "@tiptap/extension-link"

- dependency-name: "@tiptap/extension-paragraph"
- dependency-name: "@tiptap/extension-strike"
- dependency-name: "@tiptap/extension-text"
Expand Down
49 changes: 49 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,55 @@ type Link = {
};
```

### Customizing Links
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we also make sure this is reflected correctly in blocknote initialization options?

Looking at it, that part is actually currently broken in docs: https://www.blocknotejs.org/docs/reference/editor/overview#options

(maybe check for other AutoTypeTables that are broken as well)


You can customize how links are rendered and how they respond to clicks with the `links` editor option.

```ts
const editor = BlockNoteEditor.create({
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 = BlockNoteEditor.create({
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.

Returning `false` will let BlockNote run other click handlers after this one. Returning `true` or nothing (the default) marks the event as handled.

```ts
const editor = BlockNoteEditor.create({
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
4 changes: 2 additions & 2 deletions docs/content/docs/react/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ The `<BlockNoteView>` component is used to render the editor. It also provides a

### Props

<auto-type-table
path="../../../../packages/react/src/editor/BlockNoteView.tsx"
<AutoTypeTable
path="../packages/react/src/editor/BlockNoteView.tsx"
name="BlockNoteViewProps"
/>

Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/reference/editor/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ editor.pasteMarkdown("# Hello\n\nThis is **bold** text.");

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

<auto-type-table
path="../../../../../packages/core/src/editor/BlockNoteEditor.ts"
<AutoTypeTable
path="../packages/core/src/editor/BlockNoteEditor.ts"
name="BlockNoteEditorOptions"
/>

Expand Down
40 changes: 27 additions & 13 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,7 @@
],
"overrides": {
"vitest": "4.1.2",
"@vitest/runner": "4.1.2",
"msw": "2.11.5",
"ai": "6.0.5",
"@ai-sdk/anthropic": "3.0.2",
"@ai-sdk/openai": "3.0.2",
"@ai-sdk/groq": "3.0.2",
"@ai-sdk/google": "3.0.2",
"@ai-sdk/mistral": "3.0.2",
"@ai-sdk/openai-compatible": "2.0.2",
"@ai-sdk/provider-utils": "4.0.2",
"@ai-sdk/react": "3.0.5",
"@ai-sdk/gateway": "3.0.4"
"@vitest/runner": "4.1.2"
}
},
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
Expand All @@ -71,5 +60,30 @@
"start": "serve playground/dist -c ../serve.json",
"test": "nx run-many --target=test",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,css,scss,md}\""
}
},
"overrides": {
"msw": "2.11.5",
"ai": "6.0.5",
"@ai-sdk/anthropic": "3.0.2",
"@ai-sdk/openai": "3.0.2",
"@ai-sdk/groq": "3.0.2",
"@ai-sdk/google": "3.0.2",
"@ai-sdk/mistral": "3.0.2",
"@ai-sdk/openai-compatible": "2.0.2",
"@ai-sdk/provider-utils": "4.0.2",
"@ai-sdk/react": "3.0.5",
"@ai-sdk/gateway": "3.0.4",
"@headlessui/react": "^2.2.4",
"@tiptap/core": "^3.0.0",
"@tiptap/pm": "^3.0.0"
},
"workspaces": [
"packages/*",
"examples/*/*",
"playground",
"fumadocs",
"docs",
"shared",
"tests"
]
}
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@
"lint": "eslint src --max-warnings 0",
"test": "vitest --run",
"test-watch": "vitest watch",
"clean": "rimraf dist && rimraf types"
"clean": "rimraf dist && rimraf types",
"update-tlds": "node scripts/update-tlds.mjs"
},
"dependencies": {
"@emoji-mart/data": "^1.2.1",
Expand All @@ -98,7 +99,6 @@
"@tiptap/extension-code": "^3.13.0",
"@tiptap/extension-horizontal-rule": "^3.13.0",
"@tiptap/extension-italic": "^3.13.0",
"@tiptap/extension-link": "^3.22.1",
"@tiptap/extension-paragraph": "^3.13.0",
"@tiptap/extension-strike": "^3.13.0",
"@tiptap/extension-text": "^3.13.0",
Expand Down
135 changes: 135 additions & 0 deletions packages/core/scripts/update-tlds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Regenerate src/extensions/tiptap-extensions/Link/helpers/tlds.ts from IANA's
* authoritative TLD list.
*
* Run with: pnpm --filter @blocknote/core update-tlds
*
* Encoding format ported from linkifyjs (MIT, https://github.com/nfrasser/linkifyjs):
* a sorted TLD list is built into a trie, then serialized as an ASCII string
* where letters descend the trie and digit runs mean "emit a word and pop N
* levels back up." Shared TLD prefixes (e.g. construction/consulting/
* contractors) collapse, producing a payload smaller than a flat list.
*
* IDN punycode entries (XN--...) are skipped: the schemeless URL regex in
* linkDetector.ts requires ASCII-only TLDs, so unicode TLDs would never reach
* the validation step.
*/

import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";

const TLDS_URL = "https://data.iana.org/TLD/tlds-alpha-by-domain.txt";

const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_PATH = resolve(
__dirname,
"../src/extensions/tiptap-extensions/Link/helpers/tlds.ts",
);

function createTrie(words) {
const root = {};
for (const word of words) {
let current = root;
for (const letter of word) {
if (!(letter in current)) {
current[letter] = {};
}
current = current[letter];
}
current.isWord = true;
}
return root;
}

function encodeTrieHelper(trie) {
const output = [];
for (const k in trie) {
if (k === "isWord") {
output.push(0);
continue;
}
output.push(k);
output.push(...encodeTrieHelper(trie[k]));
if (typeof output[output.length - 1] === "number") {
output[output.length - 1] += 1;
} else {
output.push(1);
}
}
return output;
}

function encodeTlds(tlds) {
return encodeTrieHelper(createTrie(tlds)).join("");
}

function decodeTlds(encoded) {
const words = [];
const stack = [];
let i = 0;
const digits = "0123456789";
while (i < encoded.length) {
let popDigitCount = 0;
while (digits.indexOf(encoded[i + popDigitCount]) >= 0) {
popDigitCount++;
}
if (popDigitCount > 0) {
words.push(stack.join(""));
let popCount = parseInt(encoded.substring(i, i + popDigitCount), 10);
while (popCount-- > 0) {
stack.pop();
}
i += popDigitCount;
} else {
stack.push(encoded[i]);
i++;
}
}
return words;
}

async function main() {
console.log(`Fetching ${TLDS_URL}...`);
const response = await fetch(TLDS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch IANA TLDs: ${response.status}`);
}
const body = await response.text();

const tlds = body
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && !/^XN--/i.test(line))
.map((line) => line.toLowerCase())
.sort();

console.log(`Encoding ${tlds.length} TLDs...`);
const encoded = encodeTlds(tlds);

console.log("Round-trip asserting...");
const decoded = decodeTlds(encoded);
if (JSON.stringify(decoded) !== JSON.stringify(tlds)) {
throw new Error("Encode/decode round-trip mismatch");
}

const fileContents = `// THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.
// Source: ${TLDS_URL}
// Regenerate with: pnpm --filter @blocknote/core update-tlds
// Encoding format ported from linkifyjs (MIT) — trie collapsed into ASCII.

export const ENCODED_TLDS =
"${encoded}";
`;

writeFileSync(OUT_PATH, fileContents);
console.log(
`Wrote ${OUT_PATH} (${encoded.length} chars, ${tlds.length} TLDs)`,
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
74 changes: 74 additions & 0 deletions packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,54 @@ 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.
*
* Return `false` to let ProseMirror continue handling the click event.
* Returning `true` or nothing (the default) marks the event as handled.
*/
onClick?: (
event: MouseEvent,
editor: BlockNoteEditor<any, any, any>,
) => boolean | void;
/**
* Callback that decides whether a given `href` is a valid link. Applied at
* every gate where a link enters the document: HTML import, HTML export,
* paste, and autolink. Useful for supporting additional URI schemes (e.g.
* `vscode:`, `myapp:`) or tightening the default allowlist.
*
* Defaults to `isAllowedUri`, which allows
* `http|https|ftp|ftps|mailto|tel|callto|sms|cid|xmpp`. Import
* `isAllowedUri` from `@blocknote/core` to layer on top of the default.
*
* @example
* ```ts
* import { isAllowedUri } from "@blocknote/core";
*
* BlockNoteEditor.create({
* links: {
* isValidLink: (href) =>
* isAllowedUri(href) || href.startsWith("myapp:"),
* },
* });
* ```
*/
isValidLink?: (href: string) => boolean;
};

/**
* @deprecated, provide placeholders via dictionary instead
* @internal
Expand Down Expand Up @@ -1135,6 +1183,32 @@ export class BlockNoteEditor<
this._styleManager.createLink(url, text);
}

/**
* Find the link mark and its range at the given position.
* Returns undefined if there is no link at that position.
*/
public getLinkMarkAtPos(pos: number) {
return this._styleManager.getLinkMarkAtPos(pos);
}

/**
* Updates the link at the given position with a new URL and text.
* @param url The new link URL.
* @param text The new text to display.
* @param position The position inside the link to edit. Defaults to the current selection anchor.
*/
public editLink(url: string, text: string, position?: number) {
this._styleManager.editLink(url, text, position);
}

/**
* Removes the link at the given position, keeping the text.
* @param position The position inside the link to remove. Defaults to the current selection anchor.
*/
public deleteLink(position?: number) {
this._styleManager.deleteLink(position);
}

/**
* Checks if the block containing the text cursor can be nested.
*/
Expand Down
Loading
Loading