✨(frontend) turn pasted doc links into interlinks

Pasting a bare link to another doc on the same domain now inserts an
interlink instead of a plain hyperlink, so it benefits from the same
title-sync and navigation behaviour as links added through the `/` or
`@` menu. Pasting over a selection, inside a code block, or a link the
current user can't resolve will display the doc id instead.
This commit is contained in:
Anthony LC
2026-09-28 08:56:59 +02:00
parent cf7be5b484
commit 170c7b06f0
13 changed files with 837 additions and 60 deletions
+1
View File
@@ -75,6 +75,7 @@ and this project adheres to
`/external_api/{version}/jwks`
- 🔧(collaboration) adapt docker stack for development purpose
- 🔧(helm) run a valkey for the backend and one for yhub in dev and feature
- ✨(frontend) turn pasted doc links into interlinks #2713
### Fixed
@@ -513,6 +513,23 @@ test.describe('Doc Editor', () => {
await page.keyboard.press('Escape');
await expect(editor.getByText('@')).toBeVisible();
// Copy current url
const currentUrl = page.url();
await page.evaluate(async (url) => {
await navigator.clipboard.writeText(url);
}, currentUrl);
// Create new doc
await createDoc(page, 'new-doc', browserName, 1);
// Paste event the copied URL into the new doc's editor
await editor.focus();
await page.keyboard.press('Control+V');
// The paste url becomes an interlink to the copied doc
await expect(interlinkChild).toContainText(docChild2);
await interlinkChild.click();
await verifyDocName(page, docChild2);
});
test('it checks multiple big doc scroll to the top', async ({
@@ -140,5 +140,4 @@
.c__toast__content__children {
flex-shrink: 0;
flex-grow: 0;
}
@@ -42,7 +42,7 @@ import {
useComments,
} from '@/docs/doc-comments';
import { DocsFindReplaceStyle } from '@/docs/doc-find-replace/styles';
import { Doc } from '@/docs/doc-management';
import { type Doc } from '@/docs/doc-management/types';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { useAnalytics } from '@/libs/Analytics';
@@ -57,7 +57,7 @@ import {
} from '../hook';
import { useEditorStore } from '../stores';
import { DocsEditorStyle } from '../styles';
import { DocsBlockNoteEditor } from '../types';
import { type DocsBlockNoteEditor } from '../types';
import { randomColor, sanitizeColor } from '../utils';
import BlockNoteAI from './AI';
@@ -70,7 +70,10 @@ const AIMenuController = BlockNoteAI?.AIMenuController;
const useAI = BlockNoteAI?.useAI;
const localesBNAI = BlockNoteAI?.localesAI || {};
import { createSafeCodeBlockSpec } from './custom-blocks/CodeBlock';
import { InterlinkingLinkInlineContent } from './custom-inline-content';
import {
InterlinkingLinkInlineContent,
getPastedDocInterlink,
} from './custom-inline-content';
import XLMultiColumn from './xl-multi-column';
const localesBNMultiColumn = XLMultiColumn?.locales;
@@ -232,7 +235,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
ai: localesBNAI?.[langLocalesBNAI as keyof typeof localesBNAI],
}),
},
pasteHandler: ({ event, defaultPasteHandler }) => {
pasteHandler: ({ event, editor: pasteEditor, defaultPasteHandler }) => {
// Get clipboard data
const blocknoteData = event.clipboardData?.getData('blocknote/html');
@@ -250,6 +253,30 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
void threadStore.refreshThreads();
}
/**
* When pasting a bare link to a doc on this same domain, turn it
* into an interlink instead of a plain link, so it benefits from
* the title-sync and navigation behaviour of the interlinking system.
*/
const pastedInterlink = getPastedDocInterlink(
event,
pasteEditor as DocsBlockNoteEditor,
);
if (pastedInterlink?.docId) {
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
props: {
docId: pastedInterlink.docId,
...(pastedInterlink.blockId && {
blockId: pastedInterlink.blockId,
}),
},
},
]);
return true;
}
return defaultPasteHandler();
},
extensions: [
@@ -23,13 +23,13 @@ export type InterlinkingLinkInlineContentType = {
docId?: {
default: '';
};
blockId?: {
default: '';
};
trigger?: {
default: '/';
values: readonly ['/', '@'];
};
title?: {
default: '';
};
};
content: 'none';
};
@@ -44,6 +44,9 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
docId: {
default: '',
},
blockId: {
default: '',
},
disabled: {
default: false,
values: [true, false],
@@ -52,9 +55,6 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
default: '/',
values: ['/', '@'],
},
title: {
default: '',
},
},
content: 'none',
},
@@ -63,19 +63,19 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
* Can have 3 render states:
* 1. Disabled state: when the inline content is disabled, it renders nothing
* 2. Search state: when the inline content has no docId, it renders the search page
* 3. Linked state: when the inline content has a docId and title, it renders the linked doc
* 3. Linked state: when the inline content has a docId, it renders the linked doc.
*
* Info: We keep everything in the same inline content to easily preserve
* the element position when switching between states
*/
render: (props) => {
const { disabled, docId, title } = props.inlineContent.props;
const { disabled, docId, blockId } = props.inlineContent.props;
if (disabled) {
return null;
}
if (docId && title) {
if (docId) {
/**
* Should not happen
*/
@@ -98,19 +98,8 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
return (
<LinkSelected
docId={docId}
title={title}
blockId={blockId}
isEditable={props.editor.isEditable}
onUpdateTitle={(newTitle) =>
props.updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: docId,
title: newTitle,
trigger: props.inlineContent.props.trigger,
disabled: false,
},
})
}
/>
);
}
@@ -1,47 +1,39 @@
import { Loader } from '@gouvfr-lasuite/ui-components';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc, useDocStore } from '@/docs/doc-management/';
import {
KEY_DOC,
getEmojiAndTitle,
useDoc,
useDocStore,
useTrans,
} from '@/docs/doc-management/';
interface LinkSelectedProps {
docId: string;
title: string;
blockId?: string;
isEditable: boolean;
onUpdateTitle: (title: string) => void;
}
export const LinkSelected = ({
docId,
title,
isEditable,
onUpdateTitle,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
onUpdateTitle(doc.title);
}
/**
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
export const LinkSelected = ({ docId, blockId }: LinkSelectedProps) => {
const { data: doc, isLoading } = useDoc(
{ id: docId },
{
queryKey: [KEY_DOC, { id: docId }],
refetchOnWindowFocus: (query) => query.state.error?.status !== 403,
},
);
const { untitledDocument } = useTrans();
const href = `/docs/${docId}/${blockId ? `#${blockId}` : ''}`;
const visualHref = `/${docId}/`;
const label = doc ? doc.title || untitledDocument : visualHref;
const isHrefFallback = !isLoading && !doc;
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(label);
const { currentDoc } = useDocStore();
const isDeletedDoc = !!currentDoc?.deleted_at;
const router = useRouter();
const href = `/docs/${docId}/`;
const handleClick = (e: React.MouseEvent<HTMLSpanElement>) => {
e.preventDefault();
@@ -127,7 +119,7 @@ export const LinkSelected = ({
<Text
$weight="500"
spellCheck="false"
$size="16px"
$size={isHrefFallback ? 'sm' : 'md'}
$display="inline"
$position="relative"
$css={css`
@@ -139,7 +131,23 @@ export const LinkSelected = ({
text-underline-offset: 0.2em;
`}
>
{titleWithoutEmoji}
{isLoading ? (
<Box
as="span"
$display="inline-flex"
$css={css`
vertical-align: middle;
& .c__loader--small {
width: 14px;
height: 14px;
}
`}
>
<Loader size="small" />
</Box>
) : (
titleWithoutEmoji
)}
</Text>
</Box>
);
@@ -274,7 +274,6 @@ export const SearchPage = ({
type: 'interlinkingLinkInline',
props: {
docId: doc.id,
title: doc.title || untitledDocument,
},
});
@@ -0,0 +1,93 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { AppWrapper } from '@/tests/utils';
const mockCreateChildDoc = vi.fn();
const mockCurrentDoc = { id: 'current-doc-id' };
vi.mock('@/docs/doc-management', async () => {
const actual = await vi.importActual('@/docs/doc-management');
return {
...actual,
useDocStore: () => ({ currentDoc: mockCurrentDoc }),
useCreateChildDocTree: () => mockCreateChildDoc,
};
});
import {
getInterlinkinghMenuItems,
useGetInterlinkingMenuItems,
} from '../InterlinkingLinkInlineContent';
const t = ((key: string) => key) as unknown as Parameters<
typeof getInterlinkinghMenuItems
>[1];
describe('getInterlinkinghMenuItems', () => {
it('inserts an interlink inline content when the "link-doc" item is clicked', () => {
const insertInlineContent = vi.fn();
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
const createPage = vi.fn();
const items = getInterlinkinghMenuItems(editor, t, 'Links', createPage);
const linkDocItem = items.find((item) => item.key === 'link-doc');
linkDocItem?.onItemClick();
expect(insertInlineContent).toHaveBeenCalledWith([
{
type: 'interlinkingLinkInline',
props: { trigger: '/' },
},
]);
expect(createPage).not.toHaveBeenCalled();
});
it('delegates to createPage when the "new-sub-doc" item is clicked', () => {
const insertInlineContent = vi.fn();
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
const createPage = vi.fn();
const items = getInterlinkinghMenuItems(editor, t, 'Links', createPage);
const newSubDocItem = items.find((item) => item.key === 'new-sub-doc');
newSubDocItem?.onItemClick();
expect(createPage).toHaveBeenCalledTimes(1);
expect(insertInlineContent).not.toHaveBeenCalled();
});
it('tags both items with the given group and searchable aliases', () => {
const editor = {
insertInlineContent: vi.fn(),
} as unknown as DocsBlockNoteEditor;
const items = getInterlinkinghMenuItems(editor, t, 'Links', vi.fn());
expect(items).toHaveLength(2);
items.forEach((item) => {
expect(item.group).toBe('Links');
expect(item.aliases.length).toBeGreaterThan(0);
});
});
});
describe('useGetInterlinkingMenuItems', () => {
it('builds menu items bound to the current doc as parent', () => {
const { result } = renderHook(() => useGetInterlinkingMenuItems(), {
wrapper: AppWrapper,
});
const insertInlineContent = vi.fn();
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
const items = result.current(editor, t);
const newSubDocItem = items.find((item) => item.key === 'new-sub-doc');
newSubDocItem?.onItemClick();
expect(mockCreateChildDoc).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,195 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useRouter } from 'next/router';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
const DOC_ID = 'a1b2c3d4-e5f6-4789-a123-1234567890ab';
let mockDocQuery: { data?: { title?: string }; isLoading: boolean } = {
data: undefined,
isLoading: true,
};
let mockCurrentDoc: { deleted_at?: string | null } | undefined;
vi.mock('next/router', () => ({
useRouter: vi.fn(),
}));
vi.mock('@/docs/doc-management/', async () => {
const actual = await vi.importActual('@/docs/doc-management/');
return {
...actual,
useDoc: () => mockDocQuery,
useDocStore: () => ({ currentDoc: mockCurrentDoc }),
};
});
import { LinkSelected } from '../LinkSelected';
describe('LinkSelected', () => {
const push = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
beforeEach(() => {
push.mockClear();
openSpy.mockClear();
mockCurrentDoc = undefined;
mockDocQuery = { data: undefined, isLoading: true };
(useRouter as Mock).mockReturnValue({ push });
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows a loader while the doc is loading', () => {
mockDocQuery = { data: undefined, isLoading: true };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
expect(document.querySelector('.c__loader--small')).not.toBeNull();
});
it('renders the emoji and title once loaded', () => {
mockDocQuery = { data: { title: '📄 My document' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
expect(screen.getByText('📄')).toBeInTheDocument();
expect(screen.getByText('My document')).toBeInTheDocument();
});
it('falls back to the href when the doc has no title', () => {
mockDocQuery = { data: { title: '' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
expect(screen.getByText(`Untitled document`)).toBeInTheDocument();
});
it('navigates on click with no modifier keys', () => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
fireEvent.click(screen.getByRole('link'));
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/`);
expect(openSpy).not.toHaveBeenCalled();
});
it.each([{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }])(
'opens a new tab/window when %o click',
(modifier) => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
fireEvent.click(screen.getByRole('link'), modifier);
expect(openSpy).toHaveBeenCalledWith(`/docs/${DOC_ID}/`, '_blank');
expect(push).not.toHaveBeenCalled();
},
);
it('opens a new tab on middle-mouse (auxclick) but not on other buttons', () => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
const link = screen.getByRole('link');
fireEvent(
link,
new MouseEvent('auxclick', {
bubbles: true,
button: 0,
cancelable: true,
}),
);
expect(openSpy).not.toHaveBeenCalled();
fireEvent(
link,
new MouseEvent('auxclick', {
bubbles: true,
button: 1,
cancelable: true,
}),
);
expect(openSpy).toHaveBeenCalledWith(`/docs/${DOC_ID}/`, '_blank');
});
it('navigates on Enter but ignores other keys', () => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
const link = screen.getByRole('link');
fireEvent.keyDown(link, { key: ' ' });
expect(push).not.toHaveBeenCalled();
fireEvent.keyDown(link, { key: 'Enter' });
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/`);
});
it('includes the block id anchor in the href when provided', () => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
render(
<LinkSelected docId={DOC_ID} blockId="block-1" isEditable={true} />,
{
wrapper: AppWrapper,
},
);
fireEvent.click(screen.getByRole('link'));
expect(push).toHaveBeenCalledWith(`/docs/${DOC_ID}/#block-1`);
});
it('disables interaction when the current doc is deleted', () => {
mockDocQuery = { data: { title: 'My document' }, isLoading: false };
mockCurrentDoc = { deleted_at: '2024-01-01T00:00:00Z' };
render(<LinkSelected docId={DOC_ID} isEditable={true} />, {
wrapper: AppWrapper,
});
const link = screen.getByRole('link');
expect(link).toHaveAttribute('tabindex', '-1');
expect(link).toHaveAttribute('aria-disabled', 'true');
fireEvent.click(link);
expect(push).not.toHaveBeenCalled();
fireEvent.keyDown(link, { key: 'Enter' });
expect(push).not.toHaveBeenCalled();
fireEvent(
link,
new MouseEvent('auxclick', {
bubbles: true,
button: 1,
cancelable: true,
}),
);
expect(openSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,246 @@
import { MantineProvider } from '@mantine/core';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { useDocSearchFilterStore } from '@/docs/doc-search/stores/useDocSearchFilterStore';
import { AppWrapper } from '@/tests/utils';
const Wrapper = ({ children }: PropsWithChildren) => (
<AppWrapper>
<MantineProvider>{children}</MantineProvider>
</AppWrapper>
);
const { capturedProps } = vi.hoisted(() => ({
capturedProps: [] as unknown[],
}));
const FAKE_DOCS = [
{ id: 'doc-1', title: 'First result' },
{ id: 'doc-2', title: 'Second result' },
];
vi.mock('@/docs/doc-search', async () => {
const { QuickSearchGroup } = await vi.importActual<
typeof import('@/components/quick-search')
>('@/components/quick-search');
return {
DocSearchContent: (props: any) => {
capturedProps.push(props);
return (
<QuickSearchGroup
group={{ groupName: props.groupName, elements: FAKE_DOCS }}
onSelect={props.onSelect}
renderElement={(doc: (typeof FAKE_DOCS)[number]) => doc.title}
/>
);
},
};
});
import { SearchPage } from '../SearchPage';
// cmdk and Mantine rely on browser APIs jsdom doesn't implement.
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {
// noop
}
unobserve() {
// noop
}
disconnect() {
// noop
}
},
);
Element.prototype.scrollIntoView = vi.fn();
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
});
const renderSearchPage = async ({ isEditable = true, trigger = '/' } = {}) => {
const updateInlineContent = vi.fn();
const contentRef = vi.fn();
const insertInlineContent = vi.fn();
const focus = vi.fn();
const editor = {
isEditable,
focus,
insertInlineContent,
} as unknown as DocsBlockNoteEditor;
render(
<SearchPage
editor={editor as any}
inlineContent={
{
type: 'interlinkingLinkInline',
props: { trigger, disabled: false, docId: '' },
} as any
}
updateInlineContent={updateInlineContent}
contentRef={contentRef}
node={{} as any}
getPos={() => undefined}
/>,
{ wrapper: Wrapper },
);
// SearchPage focuses the input and opens the popover after a 100ms timeout.
await waitFor(() => expect(screen.getByRole('combobox')).toHaveFocus());
return { updateInlineContent, contentRef, insertInlineContent, focus };
};
describe('SearchPage', () => {
beforeEach(() => {
capturedProps.length = 0;
useDocSearchFilterStore.setState({ filter: 'all' });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('limits the search to the current doc subtree on mount', async () => {
await renderSearchPage();
expect(useDocSearchFilterStore.getState().filter).toBe('current');
});
it('renders the trigger character and focuses the search input', async () => {
await renderSearchPage({ trigger: '@' });
expect(screen.getByText('@')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toHaveFocus();
});
it('forwards the typed text to the search results', async () => {
await renderSearchPage();
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'my query' },
});
await waitFor(() => {
const lastCall = capturedProps[capturedProps.length - 1] as {
search: string;
};
expect(lastCall.search).toBe('my query');
});
});
it('selects a result and inserts the interlink', async () => {
const { updateInlineContent, contentRef, focus } = await renderSearchPage();
fireEvent.click(await screen.findByText('First result'));
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { docId: 'doc-1' },
});
expect(contentRef).toHaveBeenCalledWith(null);
expect(focus).toHaveBeenCalled();
});
it('ignores a selection when the editor is not editable', async () => {
const { updateInlineContent } = await renderSearchPage({
isEditable: false,
});
fireEvent.click(await screen.findByText('First result'));
expect(updateInlineContent).not.toHaveBeenCalled();
});
it('closes and re-inserts the trigger and typed text on Escape', async () => {
const { updateInlineContent, insertInlineContent, focus } =
await renderSearchPage({ trigger: '/' });
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'abc' },
});
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { disabled: true },
});
expect(focus).toHaveBeenCalled();
expect(insertInlineContent).toHaveBeenCalledWith(['/abc']);
});
it('closes without inserting anything on Backspace when the search is empty', async () => {
const { updateInlineContent, insertInlineContent } =
await renderSearchPage();
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { disabled: true },
});
expect(insertInlineContent).not.toHaveBeenCalled();
});
it('lets a Backspace with existing text fall through to normal editing', async () => {
const { updateInlineContent } = await renderSearchPage();
fireEvent.input(screen.getByRole('combobox'), {
target: { value: 'abc' },
});
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
expect(updateInlineContent).not.toHaveBeenCalled();
});
it('moves the highlighted result with ArrowDown/ArrowUp and selects it on Enter', async () => {
const { updateInlineContent } = await renderSearchPage();
await screen.findByText('First result');
const input = screen.getByRole('combobox');
// The first result is highlighted by default.
expect(
screen.getByText('First result').closest('[cmdk-item]'),
).toHaveAttribute('data-selected', 'true');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(
screen.getByText('Second result').closest('[cmdk-item]'),
).toHaveAttribute('data-selected', 'true');
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(
screen.getByText('First result').closest('[cmdk-item]'),
).toHaveAttribute('data-selected', 'true');
fireEvent.keyDown(input, { key: 'Enter' });
expect(updateInlineContent).toHaveBeenCalledWith({
type: 'interlinkingLinkInline',
props: { docId: 'doc-1' },
});
});
});
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { getPastedDocInterlink } from '../pasteInterlink';
const VALID_DOC_ID = 'a1b2c3d4-e5f6-4789-a123-1234567890ab';
const makeEditor = (
opts: { selectionEmpty?: boolean; inCodeBlock?: boolean } = {},
) => {
const { selectionEmpty = true, inCodeBlock = false } = opts;
const tr = {
selection: {
empty: selectionEmpty,
$from: { parent: { type: { spec: { code: inCodeBlock } } } },
$to: { parent: { type: { spec: { code: inCodeBlock } } } },
},
};
return {
transact: (fn: (transaction: typeof tr) => unknown) => fn(tr),
} as unknown as DocsBlockNoteEditor;
};
const makeClipboardEvent = (text: string | undefined) =>
({
clipboardData: {
getData: () => text,
},
}) as unknown as ClipboardEvent;
describe('getPastedDocInterlink', () => {
it('returns null when the editor has a non-empty selection', () => {
const editor = makeEditor({ selectionEmpty: false });
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
});
it('returns null when the cursor is inside a code block', () => {
const editor = makeEditor({ inCodeBlock: true });
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
});
it('returns null when the clipboard has no text', () => {
const editor = makeEditor();
expect(
getPastedDocInterlink(makeClipboardEvent(undefined), editor),
).toBeNull();
expect(getPastedDocInterlink(makeClipboardEvent(''), editor)).toBeNull();
expect(getPastedDocInterlink(makeClipboardEvent(' '), editor)).toBeNull();
});
it('returns null when the pasted text contains whitespace', () => {
const editor = makeEditor();
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
expect(
getPastedDocInterlink(makeClipboardEvent(`some text ${url}`), editor),
).toBeNull();
});
it('returns null when the pasted text is not a valid URL', () => {
const editor = makeEditor();
expect(
getPastedDocInterlink(makeClipboardEvent('not-a-url'), editor),
).toBeNull();
});
it('returns null for a URL on a different origin', () => {
const editor = makeEditor();
const url = `https://not-the-same-origin.example/docs/${VALID_DOC_ID}/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
});
it('returns null when the path is not a doc route', () => {
const editor = makeEditor();
const url = `${window.location.origin}/settings/${VALID_DOC_ID}/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
});
it('returns null when the doc id in the path is not a valid uuid', () => {
const editor = makeEditor();
const url = `${window.location.origin}/docs/not-a-uuid/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toBeNull();
});
it('returns the doc id for a same-origin doc URL with a trailing slash', () => {
const editor = makeEditor();
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
docId: VALID_DOC_ID,
});
});
it('returns the doc id for a same-origin doc URL without a trailing slash', () => {
const editor = makeEditor();
const url = `${window.location.origin}/docs/${VALID_DOC_ID}`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
docId: VALID_DOC_ID,
});
});
it('returns the doc id when the URL has a query string or hash', () => {
const editor = makeEditor();
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/?foo=bar#section`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
docId: VALID_DOC_ID,
blockId: 'section',
});
});
it('returns the block id from the URL hash when present', () => {
const editor = makeEditor();
const blockId = '0b0566d8-56d3-4901-8163-d887c3c3bc0c';
const url = `${window.location.origin}/docs/${VALID_DOC_ID}/#${blockId}`;
expect(getPastedDocInterlink(makeClipboardEvent(url), editor)).toEqual({
docId: VALID_DOC_ID,
blockId,
});
});
});
@@ -1 +1,2 @@
export * from './InterlinkingLinkInlineContent';
export * from './pasteInterlink';
@@ -0,0 +1,67 @@
import { validate as uuidValidate } from 'uuid';
import { type DocsBlockNoteEditor } from '@/docs/doc-editor/types';
const DOC_PATH_REGEX = /^\/docs\/([^/]+)\/?$/;
export type PastedDocInterlink = {
docId: string;
blockId?: string;
};
/**
* When the pasted content is a bare URL pointing to a doc on this same
* origin (e.g. copied from the address bar or "Copy link"), returns its
* doc id (and, if present, the anchored block id from the URL hash) so
* the paste can be turned into an interlink instead of a plain link.
* Returns null for anything else (selections, code blocks, links mixed
* with other text, other origins/routes).
*/
export const getPastedDocInterlink = (
event: ClipboardEvent,
editor: DocsBlockNoteEditor,
): PastedDocInterlink | null => {
const hasSelection = editor.transact((tr) => !tr.selection.empty);
if (hasSelection) {
return null;
}
const isInCodeBlock = editor.transact(
(tr) =>
!!tr.selection.$from.parent.type.spec.code &&
!!tr.selection.$to.parent.type.spec.code,
);
// If the cursor is inside a code block, we don't want to convert the pasted URL into an interlink.
if (isInCodeBlock) {
return null;
}
const text = event.clipboardData?.getData('text/plain')?.trim();
// If the pasted text is empty or contains whitespace, it can't be a bare URL.
if (!text || /\s/.test(text)) {
return null;
}
let url: URL;
try {
url = new URL(text);
} catch {
return null;
}
// If the URL is not from the same origin as the current site, it can't be an interlink.
if (url.origin !== window.location.origin) {
return null;
}
const match = DOC_PATH_REGEX.exec(url.pathname);
const docId = match?.[1];
if (!docId || !uuidValidate(docId)) {
return null;
}
const blockId = url.hash.slice(1) || undefined;
return blockId ? { docId, blockId } : { docId };
};