diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6af4441..cb5f0a818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index a72d4e7d6..7aabc689c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -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 ({ diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-style.css b/src/frontend/apps/impress/src/cunningham/cunningham-style.css index a02e0e364..bf3682a15 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-style.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-style.css @@ -140,5 +140,4 @@ .c__toast__content__children { flex-shrink: 0; - flex-grow: 0; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index d7a12e3c7..879473f3d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -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: [ diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx index 317e192b7..9d96099a9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx @@ -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 ( - props.updateInlineContent({ - type: 'interlinkingLinkInline', - props: { - docId: docId, - title: newTitle, - trigger: props.inlineContent.props.trigger, - disabled: false, - }, - }) - } /> ); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/LinkSelected.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/LinkSelected.tsx index 4cfe9277b..cd28ed289 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/LinkSelected.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/LinkSelected.tsx @@ -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) => { e.preventDefault(); @@ -127,7 +119,7 @@ export const LinkSelected = ({ - {titleWithoutEmoji} + {isLoading ? ( + + + + ) : ( + titleWithoutEmoji + )} ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx index 68a2347d7..ef20e42b5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -274,7 +274,6 @@ export const SearchPage = ({ type: 'interlinkingLinkInline', props: { docId: doc.id, - title: doc.title || untitledDocument, }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/InterlinkingLinkInlineContent.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/InterlinkingLinkInlineContent.test.tsx new file mode 100644 index 000000000..428494481 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/InterlinkingLinkInlineContent.test.tsx @@ -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); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/LinkSelected.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/LinkSelected.test.tsx new file mode 100644 index 000000000..06402f61f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/LinkSelected.test.tsx @@ -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(, { + 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(, { + 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(, { + wrapper: AppWrapper, + }); + + expect(screen.getByText(`Untitled document`)).toBeInTheDocument(); + }); + + it('navigates on click with no modifier keys', () => { + mockDocQuery = { data: { title: 'My document' }, isLoading: false }; + + render(, { + 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(, { + 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(, { + 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(, { + 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( + , + { + 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(, { + 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(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/SearchPage.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/SearchPage.test.tsx new file mode 100644 index 000000000..a723f1db3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/SearchPage.test.tsx @@ -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) => ( + + {children} + +); + +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 ( + 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( + 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' }, + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/pasteInterlink.test.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/pasteInterlink.test.ts new file mode 100644 index 000000000..e4e477b83 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/__tests__/pasteInterlink.test.ts @@ -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, + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts index 48eda10e4..dc6e9bdad 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts @@ -1 +1,2 @@ export * from './InterlinkingLinkInlineContent'; +export * from './pasteInterlink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/pasteInterlink.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/pasteInterlink.ts new file mode 100644 index 000000000..f7f7aa8d1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/pasteInterlink.ts @@ -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 }; +};