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
3 changes: 2 additions & 1 deletion packages/core/src/editor/BlockNoteEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ export interface BlockNoteEditorOptions<
/**
* 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.
* paste, autolink, and the link toolbar, which keeps an entered URL whose
* scheme this callback accepts instead of prepending `https://`. Useful for supporting additional URI schemes (e.g.
* `vscode:`, `myapp:`) or tightening the default allowlist.
*
* Defaults to `isAllowedUri`, which allows
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/extensions/LinkToolbar/LinkToolbar.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { posToDOMRect } from "@tiptap/core";
import { createExtension } from "../../editor/BlockNoteExtension.js";
import { isAllowedUri } from "../tiptap-extensions/Link/link.js";
import { normalizeLinkUrl } from "./protocols.js";

export const LinkToolbarExtension = createExtension(({ editor, options }) => {
const isValidLink: (href: string) => boolean =
options?.links?.isValidLink ?? isAllowedUri;

export const LinkToolbarExtension = createExtension(({ editor }) => {
function getLinkElementAtPos(pos: number) {
let currentNode = editor.prosemirrorView.nodeDOM(pos);
while (currentNode && currentNode.parentElement) {
Expand Down Expand Up @@ -61,6 +66,14 @@ export const LinkToolbarExtension = createExtension(({ editor }) => {
});
},

/**
* Prepares a URL entered in the link toolbar, prepending the default
* protocol unless the URL already has one the editor accepts.
*/
normalizeUrl(url: string) {
return normalizeLinkUrl(url, isValidLink);
},

editLink(
url: string,
text: string,
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/extensions/LinkToolbar/protocols.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vite-plus/test";

import { isAllowedUri } from "../tiptap-extensions/Link/link.js";
import { normalizeLinkUrl } from "./protocols.js";

const allowCustomScheme = (href: string) =>
href.startsWith("myapp://") || isAllowedUri(href);

describe("normalizeLinkUrl", () => {
it("keeps URLs with a default protocol", () => {
expect(normalizeLinkUrl("https://example.com", isAllowedUri)).toBe(
"https://example.com",
);
expect(normalizeLinkUrl("mailto:hi@example.com", isAllowedUri)).toBe(
"mailto:hi@example.com",
);
});

it("prepends the default protocol to addresses without one", () => {
expect(normalizeLinkUrl("example.com", isAllowedUri)).toBe(
"https://example.com",
);
expect(normalizeLinkUrl("example.com", allowCustomScheme)).toBe(
"https://example.com",
);
});

it("keeps a custom scheme accepted by isValidLink", () => {
expect(normalizeLinkUrl("myapp://page/1", allowCustomScheme)).toBe(
"myapp://page/1",
);
});

it("prepends the default protocol to a scheme isValidLink rejects", () => {
expect(normalizeLinkUrl("myapp://page/1", isAllowedUri)).toBe(
"https://myapp://page/1",
);
expect(normalizeLinkUrl("otherapp://page/1", allowCustomScheme)).toBe(
"https://otherapp://page/1",
);
});
});
28 changes: 28 additions & 0 deletions packages/core/src/extensions/LinkToolbar/protocols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,31 @@ export const VALID_LINK_PROTOCOLS = [
"xmpp",
];
export const DEFAULT_LINK_PROTOCOL = "https";

const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;

/**
* Prepares a URL entered in the link toolbar before it is applied.
*
* URLs starting with one of the `VALID_LINK_PROTOCOLS` are kept as they are.
* So is a URL with any other explicit scheme (e.g. `myapp://page`) when
* `isValidLink` accepts it, which lets the toolbar honor the editor's
* `links.isValidLink` option. Everything else is treated as an address
* without a protocol and gets `DEFAULT_LINK_PROTOCOL` prepended.
*/
export function normalizeLinkUrl(
url: string,
isValidLink: (href: string) => boolean,
): string {
for (const protocol of VALID_LINK_PROTOCOLS) {
if (url.startsWith(protocol)) {
return url;
}
}

if (URI_SCHEME_REGEX.test(url) && isValidLink(url)) {
return url;
}

return `${DEFAULT_LINK_PROTOCOL}://${url}`;
}
26 changes: 6 additions & 20 deletions packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
DEFAULT_LINK_PROTOCOL,
LinkToolbarExtension,
VALID_LINK_PROTOCOLS,
} from "@blocknote/core/extensions";
import { LinkToolbarExtension } from "@blocknote/core/extensions";
import { ChangeEvent, useCallback, useEffect, useState } from "react";
import { RiLink, RiText } from "react-icons/ri";
import { useComponentsContext } from "../../editor/ComponentsContext.js";
Expand All @@ -11,16 +7,6 @@ import { useExtension } from "../../hooks/useExtension.js";
import { useDictionary } from "../../i18n/dictionary.js";
import { LinkToolbarProps } from "./LinkToolbarProps.js";

const validateUrl = (url: string) => {
for (const protocol of VALID_LINK_PROTOCOLS) {
if (url.startsWith(protocol)) {
return url;
}
}

return `${DEFAULT_LINK_PROTOCOL}://${url}`;
};

export const EditLinkMenuItems = (
props: Pick<
LinkToolbarProps,
Expand All @@ -33,8 +19,8 @@ export const EditLinkMenuItems = (
const Components = useComponentsContext()!;
const dict = useDictionary();

// eslint-disable-next-line @typescript-eslint/unbound-method -- editLink is a plain object method, not a class method
const { editLink } = useExtension(LinkToolbarExtension);
// eslint-disable-next-line @typescript-eslint/unbound-method -- editLink and normalizeUrl are plain object methods, not class methods
const { editLink, normalizeUrl } = useExtension(LinkToolbarExtension);

const { url, text, showTextField } = props;

Expand Down Expand Up @@ -64,15 +50,15 @@ export const EditLinkMenuItems = (
const handleSubmit = useCallback(() => {
if (props.onSubmit) {
props.onSubmit(
currentUrl === "" ? currentUrl : validateUrl(currentUrl),
currentUrl === "" ? currentUrl : normalizeUrl(currentUrl),
currentText,
);
} else {
editLink(validateUrl(currentUrl), currentText, props.range.from);
editLink(normalizeUrl(currentUrl), currentText, props.range.from);
}
props.setToolbarOpen?.(false);
props.setToolbarPositionFrozen?.(false);
}, [editLink, currentUrl, currentText, props]);
}, [editLink, normalizeUrl, currentUrl, currentText, props]);

return (
<Components.Generic.Form.Root
Expand Down