diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index c5087ae1ee..49be822909 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -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 diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts index a4377ab599..750a4ec40c 100644 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts @@ -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) { @@ -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, diff --git a/packages/core/src/extensions/LinkToolbar/protocols.test.ts b/packages/core/src/extensions/LinkToolbar/protocols.test.ts new file mode 100644 index 0000000000..fc3c4b36e8 --- /dev/null +++ b/packages/core/src/extensions/LinkToolbar/protocols.test.ts @@ -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", + ); + }); +}); diff --git a/packages/core/src/extensions/LinkToolbar/protocols.ts b/packages/core/src/extensions/LinkToolbar/protocols.ts index e021b08df4..ca0e2de1fc 100644 --- a/packages/core/src/extensions/LinkToolbar/protocols.ts +++ b/packages/core/src/extensions/LinkToolbar/protocols.ts @@ -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}`; +} diff --git a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx index e545d630a3..f4a4debac7 100644 --- a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx +++ b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx @@ -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"; @@ -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, @@ -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; @@ -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 (