diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1f54c1f3..3ce60495df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(backend) allow masking documents from the list view #1171 - ✨(frontend) subdocs can manage link reach #1190 - ✨(frontend) add duplicate action to doc tree #1175 +- ✨(frontend) Interlinking doc #904 - ✨(frontend) add multi columns support for editor #1219 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index c1c25d0a51..f05e1f24f4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -29,6 +29,50 @@ test.describe('Doc Create', () => { await expect(page.getByTestId('grid-loader')).toBeHidden(); await expect(docsGrid.getByText(docTitle)).toBeVisible(); }); + + test('it creates a sub doc from slash menu editor', async ({ + page, + browserName, + }) => { + const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1); + + await verifyDocName(page, title); + + await page.locator('.bn-block-outer').last().fill('/'); + await page + .getByText('New sub-doc', { + exact: true, + }) + .click(); + + const input = page.getByRole('textbox', { name: 'doc title input' }); + await expect(input).toHaveText(''); + await expect( + page.locator('.c__tree-view--row-content').getByText('Untitled document'), + ).toBeVisible(); + }); + + test('it creates a sub doc from interlinking dropdown', async ({ + page, + browserName, + }) => { + const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1); + + await verifyDocName(page, title); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link a doc').first().click(); + await page + .locator('.quick-search-container') + .getByText('New sub-doc') + .click(); + + const input = page.getByRole('textbox', { name: 'doc title input' }); + await expect(input).toHaveText(''); + await expect( + page.locator('.c__tree-view--row-content').getByText('Untitled document'), + ).toBeVisible(); + }); }); test.describe('Doc Create: Not logged', () => { 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 7a450c1aac..41098f2526 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 @@ -706,4 +706,75 @@ test.describe('Doc Editor', () => { 'pink', ); }); + + test('it checks interlink feature', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1); + + await verifyDocName(page, randomDoc); + + const { name: docChild1 } = await createRootSubPage( + page, + browserName, + 'doc-interlink-child-1', + ); + + await verifyDocName(page, docChild1); + + const { name: docChild2 } = await createRootSubPage( + page, + browserName, + 'doc-interlink-child-2', + ); + + await verifyDocName(page, docChild2); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link a doc').first().click(); + + const input = page.locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ); + const searchContainer = page.locator('.quick-search-container'); + + await input.fill('doc-interlink'); + + await expect(searchContainer.getByText(randomDoc)).toBeVisible(); + await expect(searchContainer.getByText(docChild1)).toBeVisible(); + await expect(searchContainer.getByText(docChild2)).toBeVisible(); + + await input.pressSequentially('-child'); + + await expect(searchContainer.getByText(docChild1)).toBeVisible(); + await expect(searchContainer.getByText(docChild2)).toBeVisible(); + await expect(searchContainer.getByText(randomDoc)).toBeHidden(); + + // use keydown to select the second result + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + const interlink = page.getByRole('link', { + name: 'child-2', + }); + + await expect(interlink).toBeVisible(); + await interlink.click(); + + await verifyDocName(page, docChild2); + }); + + test('it checks interlink shortcut @', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1); + + await verifyDocName(page, randomDoc); + + const editor = page.locator('.bn-block-outer').last(); + await editor.click(); + await page.keyboard.press('@'); + + await expect( + page.locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ), + ).toBeVisible(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index b70ebd0111..f66aecf833 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -5,6 +5,7 @@ import cs from 'convert-stream'; import pdf from 'pdf-parse'; import { createDoc, verifyDocName } from './utils-common'; +import { createRootSubPage } from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -411,4 +412,72 @@ test.describe('Doc Export', () => { expect(pdfData.text).toContain('Column 2'); expect(pdfData.text).toContain('Column 3'); }); + + test('it exports the doc with interlinking', async ({ + page, + browserName, + }) => { + const [randomDoc] = await createDoc( + page, + 'export-interlinking', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + + const { name: docChild } = await createRootSubPage( + page, + browserName, + 'export-interlink-child', + ); + + await verifyDocName(page, docChild); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link a doc').first().click(); + + await page + .locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ) + .fill('interlink-child'); + + await page + .locator('.quick-search-container') + .getByText('interlink-child') + .click(); + + const interlink = page.getByRole('link', { + name: 'interlink-child', + }); + + await expect(interlink).toBeVisible(); + + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${docChild}.pdf`); + }); + + await page + .getByRole('button', { + name: 'download', + exact: true, + }) + .click(); + + void page + .getByRole('button', { + name: 'Download', + exact: true, + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${docChild}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfData = await pdf(pdfBuffer); + + expect(pdfData.text).toContain('interlink-child'); // This is the pdf text + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts index ba09d36e5c..68ed84d908 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -179,7 +179,7 @@ test.describe('Doc grid dnd mobile', () => { await expect(docsGrid.getByRole('row').first()).toBeVisible(); await expect(docsGrid.locator('.--docs--grid-droppable')).toHaveCount(0); - await createDoc(page, 'Draggable doc mobile', browserName, 1, false, true); + await createDoc(page, 'Draggable doc mobile', browserName, 1, true); await createRootSubPage( page, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts index 6f8eb7c6e3..189dbc1e3a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -16,18 +16,11 @@ test.describe('Doc Routing', () => { await page.goto('/'); }); - test('Check the presence of the meta tag noindex', async ({ page }) => { - const buttonCreateHomepage = page.getByRole('button', { - name: 'New doc', - }); - - await expect(buttonCreateHomepage).toBeVisible(); - await buttonCreateHomepage.click(); - await expect( - page.getByRole('button', { - name: 'Share', - }), - ).toBeVisible(); + test('Check the presence of the meta tag noindex', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-routing-test', browserName, 1); const metaDescription = page.locator('meta[name="robots"]'); await expect(metaDescription).toHaveAttribute('content', 'noindex'); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 49445f38fe..dc3c412e50 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -78,17 +78,11 @@ export const createDoc = async ( docName: string, browserName: string, length: number = 1, - isChild: boolean = false, isMobile: boolean = false, ) => { const randomDocs = randomName(docName, browserName, length); for (let i = 0; i < randomDocs.length; i++) { - if (!isChild && !isMobile) { - const header = page.locator('header').first(); - await header.locator('h2').getByText('Docs').click(); - } - if (isMobile) { await page .getByRole('button', { name: 'Open the header menu' }) diff --git a/src/frontend/apps/impress/src/components/Box.tsx b/src/frontend/apps/impress/src/components/Box.tsx index 84e3205716..448a009072 100644 --- a/src/frontend/apps/impress/src/components/Box.tsx +++ b/src/frontend/apps/impress/src/components/Box.tsx @@ -16,6 +16,7 @@ export interface BoxProps { $background?: CSSProperties['background']; $color?: CSSProperties['color']; $css?: string | RuleSet; + $cursor?: CSSProperties['cursor']; $direction?: CSSProperties['flexDirection']; $display?: CSSProperties['display']; $effect?: 'show' | 'hide'; @@ -44,13 +45,13 @@ export interface BoxProps { export type BoxType = ComponentPropsWithRef; export const Box = styled('div')` - display: flex; - flex-direction: column; ${({ $align }) => $align && `align-items: ${$align};`} ${({ $background }) => $background && `background: ${$background};`} ${({ $color }) => $color && `color: ${$color};`} - ${({ $direction }) => $direction && `flex-direction: ${$direction};`} - ${({ $display }) => $display && `display: ${$display};`} + ${({ $cursor }) => $cursor && `cursor: ${$cursor};`} + ${({ $direction }) => `flex-direction: ${$direction || 'column'};`} + ${({ $display, as }) => + `display: ${$display || as?.match('span|input') ? 'inline-flex' : 'flex'};`} ${({ $flex }) => $flex && `flex: ${$flex};`} ${({ $gap }) => $gap && `gap: ${$gap};`} ${({ $height }) => $height && `height: ${$height};`} diff --git a/src/frontend/apps/impress/src/components/BoxButton.tsx b/src/frontend/apps/impress/src/components/BoxButton.tsx index 3b8d775110..15d1b34030 100644 --- a/src/frontend/apps/impress/src/components/BoxButton.tsx +++ b/src/frontend/apps/impress/src/components/BoxButton.tsx @@ -31,11 +31,11 @@ const BoxButton = forwardRef( $background="none" $margin="none" $padding="none" + $hasTransition $css={css` cursor: ${props.disabled ? 'not-allowed' : 'pointer'}; border: none; outline: none; - transition: all 0.2s ease-in-out; font-family: inherit; color: ${props.disabled diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index fcbd9ebe46..5700aafe34 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -25,6 +25,7 @@ export type DropdownMenuProps = { arrowCss?: BoxProps['$css']; buttonCss?: BoxProps['$css']; disabled?: boolean; + opened?: boolean; topMessage?: string; selectedValues?: string[]; afterOpenChange?: (isOpen: boolean) => void; @@ -38,12 +39,13 @@ export const DropdownMenu = ({ arrowCss, buttonCss, label, + opened, topMessage, afterOpenChange, selectedValues, }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(opened ?? false); const blockButtonRef = useRef(null); const onOpenChange = (isOpen: boolean) => { diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index a0f256f53b..bb42672469 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -4,6 +4,7 @@ export * from './BoxButton'; export * from './Card'; export * from './DropButton'; export * from './DropdownMenu'; +export * from './quick-search'; export * from './Icon'; export * from './InfiniteScroll'; export * from './Link'; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx index 2788792786..9c0ad62f45 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx @@ -1,5 +1,11 @@ import { Command } from 'cmdk'; -import { ReactNode, useRef } from 'react'; +import { + PropsWithChildren, + ReactNode, + useEffect, + useRef, + useState, +} from 'react'; import { hasChildrens } from '@/utils/children'; @@ -30,7 +36,6 @@ export type QuickSearchProps = { loading?: boolean; label?: string; placeholder?: string; - children?: ReactNode; }; export const QuickSearch = ({ @@ -42,14 +47,47 @@ export const QuickSearch = ({ label, placeholder, children, -}: QuickSearchProps) => { +}: PropsWithChildren) => { const ref = useRef(null); + const [selectedValue, setSelectedValue] = useState(''); + + // Auto-select first item when children change + useEffect(() => { + if (!children) { + setSelectedValue(''); + return; + } + + // Small delay for DOM to update + const timeoutId = setTimeout(() => { + const firstItem = ref.current?.querySelector('[cmdk-item]'); + if (firstItem) { + const value = + firstItem.getAttribute('data-value') || + firstItem.getAttribute('value') || + firstItem.textContent?.trim() || + ''; + if (value) { + setSelectedValue(value); + } + } + }, 50); + + return () => clearTimeout(timeoutId); + }, [children]); return ( <>
- + {showInput && ( ({ key={group.groupName} heading={group.groupName} forceMount={false} + contentEditable={false} > {group.startActions?.map((action, index) => { return ( @@ -58,7 +59,13 @@ export const QuickSearchGroup = ({ ); })} {group.emptyString && group.elements.length === 0 && ( - {group.emptyString} + + {group.emptyString} + )} diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx index 58aca88d36..766db7f16d 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -1,133 +1,136 @@ import { createGlobalStyle } from 'styled-components'; export const QuickSearchStyle = createGlobalStyle` - .quick-search-container { - [cmdk-root] { - width: 100%; - background: #ffffff; - border-radius: 12px; - overflow: hidden; - transition: transform 100ms ease; - outline: none; - } - - [cmdk-input] { - border: none; - width: 100%; - font-size: 17px; - padding: 8px; - background: white; + & *:focus-visible { outline: none; - color: var(--c--theme--colors--greyscale-1000); - border-radius: 0; + } - &::placeholder { - color: var(--c--theme--colors--greyscale-500); + .quick-search-container { + [cmdk-root] { + width: 100%; + background: #ffffff; + border-radius: 12px; + overflow: hidden; + transition: transform 100ms ease; + outline: none; } - } - [cmdk-item] { - content-visibility: auto; - cursor: pointer; - border-radius: var(--c--theme--spacings--xs); - font-size: 14px; - display: flex; - align-items: center; - gap: 8px; - user-select: none; - will-change: background, color; - transition: all 150ms ease; - transition-property: none; - - .show-right-on-focus { - opacity: 0; + [cmdk-input] { + border: none; + width: 100%; + font-size: 17px; + padding: 8px; + background: white; + outline: none; + color: var(--c--theme--colors--greyscale-1000); + border-radius: 0; + + &::placeholder { + color: var(--c--theme--colors--greyscale-500); + } } - &:hover, - &[data-selected='true'] { - background: var(--c--theme--colors--greyscale-100); + [cmdk-item] { + content-visibility: auto; + cursor: pointer; + border-radius: var(--c--theme--spacings--xs); + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + .show-right-on-focus { - opacity: 1; + opacity: 0; + } + + &:hover, + &[data-selected='true'] { + background: var(--c--theme--colors--greyscale-100); + .show-right-on-focus { + opacity: 1; + } + } + + &[data-disabled='true'] { + color: var(--c--theme--colors--greyscale-500); + cursor: not-allowed; } - } - &[data-disabled='true'] { - color: var(--c--theme--colors--greyscale-500); - cursor: not-allowed; + & + [cmdk-item] { + margin-top: 4px; + } } - & + [cmdk-item] { - margin-top: 4px; + [cmdk-list] { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; } - } - [cmdk-list] { - flex:1; - overflow-y: auto; - overscroll-behavior: contain; - } + [cmdk-vercel-shortcuts] { + display: flex; + margin-left: auto; + gap: 8px; + + kbd { + font-size: 12px; + min-width: 20px; + padding: 4px; + height: 20px; + border-radius: 4px; + color: white; + background: var(--c--theme--colors--greyscale-500); + display: inline-flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + } + } - [cmdk-vercel-shortcuts] { - display: flex; - margin-left: auto; - gap: 8px; - - kbd { - font-size: 12px; - min-width: 20px; - padding: 4px; - height: 20px; - border-radius: 4px; - color: white; + [cmdk-separator] { + height: 1px; + width: 100%; background: var(--c--theme--colors--greyscale-500); - display: inline-flex; - align-items: center; - justify-content: center; - text-transform: uppercase; + margin: 4px 0; } - } - - [cmdk-separator] { - height: 1px; - width: 100%; - background: var(--c--theme--colors--greyscale-500); - margin: 4px 0; - } - *:not([hidden]) + [cmdk-group] { - margin-top: 8px; - } + *:not([hidden]) + [cmdk-group] { + margin-top: 8px; + } - [cmdk-group-heading] { - user-select: none; - font-size: var(--c--theme--font--sizes--sm); - color: var(--c--theme--colors--greyscale-700); - font-weight: bold; + [cmdk-group-heading] { + user-select: none; + font-size: var(--c--theme--font--sizes--sm); + color: var(--c--theme--colors--greyscale-700); + font-weight: bold; - display: flex; - align-items: center; - margin-bottom: var(--c--theme--spacings--xs); - } + display: flex; + align-items: center; + margin-bottom: var(--c--theme--spacings--xs); + } - [cmdk-empty] { + [cmdk-empty] { + } } -} -.c__modal__scroller:has(.quick-search-container), -.c__modal__scroller:has(.noPadding) { - padding: 0 !important; + .c__modal__scroller:has(.quick-search-container), + .c__modal__scroller:has(.noPadding) { + padding: 0 !important; - .c__modal__close .c__button { - right: 5px; - top: 5px; - padding: 1.5rem 1rem; - } + .c__modal__close .c__button { + right: 5px; + top: 5px; + padding: 1.5rem 1rem; + } - .c__modal__title { - font-size: var(--c--theme--font--sizes--xs); - - padding: var(--c--theme--spacings--base); - margin-bottom: 0; + .c__modal__title { + font-size: var(--c--theme--font--sizes--xs); + padding: var(--c--theme--spacings--base); + margin-bottom: 0; + } } -} `; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg new file mode 100644 index 0000000000..87d445d678 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg new file mode 100644 index 0000000000..bcb7b88154 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg new file mode 100644 index 0000000000..219f8f1709 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg @@ -0,0 +1,30 @@ + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg new file mode 100644 index 0000000000..f36a74afbe --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg @@ -0,0 +1,6 @@ + + + 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 6709e97cef..6b7b77a483 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 @@ -2,6 +2,7 @@ import { codeBlock } from '@blocknote/code-block'; import { BlockNoteSchema, defaultBlockSpecs, + defaultInlineContentSpecs, withPageBreak, } from '@blocknote/core'; import '@blocknote/core/fonts/inter.css'; @@ -18,8 +19,13 @@ import { Box, TextErrors } from '@/components'; import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; -import { useHeadings, useUploadFile, useUploadStatus } from '../hook/'; -import useSaveDoc from '../hook/useSaveDoc'; +import { + useHeadings, + useSaveDoc, + useShortcuts, + useUploadFile, + useUploadStatus, +} from '../hook'; import { useEditorStore } from '../stores'; import { cssEditor } from '../styles'; import { DocsBlockNoteEditor } from '../types'; @@ -28,6 +34,10 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { CalloutBlock, DividerBlock } from './custom-blocks'; +import { + InterlinkingLinkInlineContent, + InterlinkingSearchInlineContent, +} from './custom-inline-content'; import XLMultiColumn from './xl-multi-column'; const multiColumnDropCursor = XLMultiColumn?.multiColumnDropCursor; @@ -41,6 +51,11 @@ const baseBlockNoteSchema = withPageBreak( callout: CalloutBlock, divider: DividerBlock, }, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + interlinkingSearchInline: InterlinkingSearchInlineContent, + interlinkingLinkInline: InterlinkingLinkInlineContent, + }, }), ); @@ -143,6 +158,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { ); useHeadings(editor); + useShortcuts(editor); useUploadStatus(editor); useEffect(() => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index 9284e81f23..4e8c6e3091 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -9,28 +9,49 @@ import { import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { DocsBlockSchema } from '../types'; +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../types'; import { getCalloutReactSlashMenuItems, getDividerReactSlashMenuItems, } from './custom-blocks'; +import { useGetInterlinkingMenuItems } from './custom-inline-content'; import XLMultiColumn from './xl-multi-column'; const getMultiColumnSlashMenuItems = XLMultiColumn?.getMultiColumnSlashMenuItems; export const BlockNoteSuggestionMenu = () => { - const editor = useBlockNoteEditor(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; + const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); const getSlashMenuItems = useMemo(() => { + // We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks + const defaultMenu = getDefaultReactSlashMenuItems(editor); + const index = defaultMenu.findIndex( + (item) => item.aliases?.includes('code') && item.aliases?.includes('pre'), + ); + const newSlashMenuItems = [ + ...defaultMenu.slice(0, index + 1), + ...getInterlinkingMenuItems(editor, t), + ...defaultMenu.slice(index + 1), + ]; + return async (query: string) => Promise.resolve( filterSuggestionItems( combineByGroup( - getDefaultReactSlashMenuItems(editor), + newSlashMenuItems, getCalloutReactSlashMenuItems(editor, t, basicBlocksName), getMultiColumnSlashMenuItems?.(editor) || [], getPageBreakReactSlashMenuItems(editor), @@ -39,7 +60,7 @@ export const BlockNoteSuggestionMenu = () => { query, ), ); - }, [basicBlocksName, editor, t]); + }, [basicBlocksName, editor, getInterlinkingMenuItems, t]); return ( { + const { data: doc } = useDoc({ id: inlineContent.props.docId }); + + useEffect(() => { + if (doc?.title && doc.title !== inlineContent.props.title) { + updateInlineContent({ + type: 'interlinkingLinkInline', + props: { + ...inlineContent.props, + title: doc.title, + }, + }); + } + }, [inlineContent.props, doc?.title, updateInlineContent]); + + return ; + }, + }, +); + +interface LinkSelectedProps { + url: string; + title: string; +} +const LinkSelected = ({ url, title }: LinkSelectedProps) => { + const { colorsTokens } = useCunninghamTheme(); + + return ( + + + + {title} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx new file mode 100644 index 0000000000..94d56da697 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { createReactInlineContentSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; + +import { DocsBlockNoteEditor } from '@/docs/doc-editor'; +import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.svg'; +import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; +import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management'; + +import { SearchPage } from './SearchPage'; + +export const InterlinkingSearchInlineContent = createReactInlineContentSpec( + { + type: 'interlinkingSearchInline', + propSchema: { + trigger: { + default: '/', + values: ['/', '@'], + }, + disabled: { + default: false, + values: [true, false], + }, + }, + content: 'styled', + }, + { + render: (props) => { + if (props.inlineContent.props.disabled) { + return null; + } + + return ( + + ); + }, + }, +); + +export const getInterlinkinghMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, + createPage: () => void, +) => [ + { + title: t('Link a doc'), + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'interlinkingSearchInline', + props: { + disabled: false, + trigger: '/', + }, + }, + ]); + }, + aliases: ['interlinking', 'link', 'anchor', 'a'], + group, + icon: , + subtext: t('Link this doc to another doc'), + }, + { + title: t('New sub-doc'), + onItemClick: createPage, + aliases: ['new sub-doc'], + group, + icon: , + subtext: t('Create a new sub-doc'), + }, +]; + +export const useGetInterlinkingMenuItems = () => { + const { currentDoc } = useDocStore(); + const createChildDoc = useCreateChildDocTree(currentDoc?.id); + + return ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + ) => getInterlinkinghMenuItems(editor, t, t('Links'), createChildDoc); +}; 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 new file mode 100644 index 0000000000..4de6fd4c8c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -0,0 +1,319 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { + PartialCustomInlineContentFromConfig, + StyleSchema, +} from '@blocknote/core'; +import { useBlockNoteEditor } from '@blocknote/react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { + Box, + Card, + Icon, + QuickSearch, + QuickSearchGroup, + QuickSearchItemContent, + Text, +} from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '@/docs/doc-editor'; +import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg'; +import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; +import { + useCreateChildDocTree, + useDocStore, + useTrans, +} from '@/docs/doc-management'; +import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search'; +import { useResponsiveStore } from '@/stores'; + +const inputStyle = css` + background-color: var(--c--theme--colors--greyscale-100); + border: none; + outline: none; + color: var(--c--theme--colors--greyscale-700); + font-size: 16px; + width: 100%; + font-family: 'Inter'; +`; + +type SearchPageProps = { + trigger: string; + updateInlineContent: ( + update: PartialCustomInlineContentFromConfig< + { + type: string; + propSchema: { + disabled: { + default: boolean; + }; + trigger: { + default: string; + }; + }; + content: 'styled'; + }, + StyleSchema + >, + ) => void; + contentRef: (node: HTMLElement | null) => void; +}; + +export const SearchPage = ({ + contentRef, + trigger, + updateInlineContent, +}: SearchPageProps) => { + const { colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + const { t } = useTranslation(); + const { currentDoc } = useDocStore(); + const createChildDoc = useCreateChildDocTree(currentDoc?.id); + const inputRef = useRef(null); + const [search, setSearch] = useState(''); + const { isDesktop } = useResponsiveStore(); + const { untitledDocument } = useTrans(); + + /** + * createReactInlineContentSpec add automatically the focus after + * the inline content, so we need to set the focus on the input + * after the component is mounted. + */ + useEffect(() => { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + }, [inputRef]); + + return ( + + + {' '} + {trigger} + { + const value = (e.target as HTMLInputElement).value; + setSearch(value); + }} + onKeyDown={(e) => { + if ( + (e.key === 'Backspace' && search.length === 0) || + e.key === 'Escape' + ) { + e.preventDefault(); + + updateInlineContent({ + type: 'interlinkingSearchInline', + props: { + disabled: true, + trigger, + }, + }); + + contentRef(null); + editor.focus(); + editor.insertInlineContent(['']); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + // Allow arrow keys to be handled by the command menu for navigation + const commandList = e.currentTarget + .closest('.inline-content') + ?.nextElementSibling?.querySelector('[cmdk-list]'); + + // Create a synthetic keyboard event for the command menu + const syntheticEvent = new KeyboardEvent('keydown', { + key: e.key, + bubbles: true, + cancelable: true, + }); + commandList?.dispatchEvent(syntheticEvent); + e.preventDefault(); + } else if (e.key === 'Enter') { + // Handle Enter key to select the currently highlighted item + const selectedItem = e.currentTarget + .closest('.inline-content') + ?.nextElementSibling?.querySelector( + '[cmdk-item][data-selected="true"]', + ) as HTMLElement; + + selectedItem?.click(); + e.preventDefault(); + } + }} + /> + + + + div { + margin-top: 0; + & [cmdk-group-heading] { + padding: 0.4rem; + margin: 0; + } + + & [cmdk-group-items] .ml-b { + margin-left: 0rem; + padding: 0.5rem; + font-size: 14px; + display: block; + } + + & [cmdk-item] { + border-radius: 0; + } + + & .--docs--doc-search-item > div { + gap: 0.8rem; + } + } + `} + $margin={{ top: '0.5rem' }} + > + { + updateInlineContent({ + type: 'interlinkingSearchInline', + props: { + disabled: true, + trigger, + }, + }); + + editor.insertInlineContent([ + { + type: 'interlinkingLinkInline', + props: { + url: `/docs/${doc.id}`, + docId: doc.id, + title: doc.title || untitledDocument, + }, + }, + ]); + + editor.focus(); + }} + renderElement={(doc) => ( + + + + {doc.title} + + + } + right={ + + } + /> + )} + /> + + + + + {t('New sub-doc')} + + + + ), + }, + ], + }} + /> + + + + + ); +}; 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 new file mode 100644 index 0000000000..3fabd144a1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts @@ -0,0 +1,2 @@ +export * from './InterlinkingLinkInlineContent'; +export * from './InterlinkingSearchInlineContent'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts new file mode 100644 index 0000000000..fa505a9e9f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts @@ -0,0 +1 @@ +export * from './Interlinking'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index a2f419381d..28a5989572 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -5,7 +5,7 @@ import * as Y from 'yjs'; import { AppWrapper } from '@/tests/utils'; -import useSaveDoc from '../useSaveDoc'; +import { useSaveDoc } from '../useSaveDoc'; jest.mock('next/router', () => ({ useRouter: jest.fn(), diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts index 3934dfa2d2..95a0804b22 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -1,3 +1,4 @@ export * from './useHeadings'; export * from './useSaveDoc'; +export * from './useShortcuts'; export * from './useUploadFile'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index c6ca782e1b..57656a6343 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -10,7 +10,7 @@ import { toBase64 } from '../utils'; const SAVE_INTERVAL = 60000; -const useSaveDoc = ( +export const useSaveDoc = ( docId: string, yDoc: Y.Doc, canSave: boolean, @@ -105,5 +105,3 @@ const useSaveDoc = ( }; }, [router.events, saveDoc]); }; - -export default useSaveDoc; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx new file mode 100644 index 0000000000..f2f3722fab --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; + +import { DocsBlockNoteEditor } from '../types'; + +export const useShortcuts = (editor: DocsBlockNoteEditor) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === '@' && editor?.isFocused()) { + const selection = window.getSelection(); + const previousChar = + selection?.anchorNode?.textContent?.charAt( + selection.anchorOffset - 1, + ) || ''; + + if (![' ', ''].includes(previousChar)) { + return; + } + + event.preventDefault(); + editor.insertInlineContent([ + { + type: 'interlinkingSearchInline', + props: { + disabled: false, + trigger: '@', + }, + }, + ]); + } + }; + + // Attach the event listener to the document instead of the window + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [editor]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png b/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png new file mode 100644 index 0000000000..d4375fb49e Binary files /dev/null and b/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png differ diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx index 19c253b7db..89ebb5ea7d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx @@ -10,7 +10,7 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping */ if (Array.isArray(block.content)) { block.content.forEach((content) => { - if (content.type === 'text' && !content.text) { + if (content.type === 'text' && 'text' in content && !content.text) { content.text = ' '; } }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx index bcdaa68c8b..1691386f72 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx @@ -8,11 +8,17 @@ export const blockMappingQuoteDocx: DocsExporterDocx['mappings']['blockMapping'] if (Array.isArray(block.content)) { block.content.forEach((content) => { if (content.type === 'text') { - content.styles = { - ...content.styles, - italic: true, - textColor: 'gray', - }; + if ( + 'styles' in content && + typeof content.styles === 'object' && + content.styles !== null + ) { + content.styles = { + ...content.styles, + italic: true, + textColor: 'gray', + }; + } } }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts new file mode 100644 index 0000000000..0b037c17d6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts @@ -0,0 +1,2 @@ +export * from './interlinkingLinkPDF'; +export * from './interlinkingLinkDocx'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx new file mode 100644 index 0000000000..afb8c0e7d4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx @@ -0,0 +1,16 @@ +import { ExternalHyperlink, TextRun } from 'docx'; + +import { DocsExporterDocx } from '../types'; + +export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] = + (inline) => { + return new ExternalHyperlink({ + children: [ + new TextRun({ + text: `📄${inline.props.title}`, + bold: true, + }), + ], + link: window.location.origin + inline.props.url, + }); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx new file mode 100644 index 0000000000..157327228f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx @@ -0,0 +1,22 @@ +/* eslint-disable jsx-a11y/alt-text */ +import { Image, Link, Text } from '@react-pdf/renderer'; + +import DocSelectedIcon from '../assets/doc-selected.png'; +import { DocsExporterPDF } from '../types'; + +export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] = + (inline) => { + return ( + + {' '} + {' '} + {inline.props.title}{' '} + + ); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 434daa995b..46263b92c6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -1,4 +1,5 @@ import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter'; +import { Paragraph } from 'docx'; import { blockMappingCalloutDocx, @@ -6,6 +7,7 @@ import { blockMappingImageDocx, blockMappingQuoteDocx, } from './blocks-mapping'; +import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping'; import { DocsExporterDocx } from './types'; export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { @@ -17,4 +19,9 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { quote: blockMappingQuoteDocx, image: blockMappingImageDocx, }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + interlinkingSearchInline: () => new Paragraph(''), + interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx, + }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 4224045d15..53cc90618e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -9,6 +9,7 @@ import { blockMappingQuotePDF, blockMappingTablePDF, } from './blocks-mapping'; +import { inlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping'; import { DocsExporterPDF } from './types'; export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { @@ -23,4 +24,9 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { quote: blockMappingQuotePDF, table: blockMappingTablePDF, }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + interlinkingSearchInline: () => <>, + interlinkingLinkInline: inlineContentMappingInterlinkingLinkPDF, + }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 3a0f3437b5..6fcabd966e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,3 +1,4 @@ +export * from './useCreateChildDoc'; export * from './useCreateDoc'; export * from './useCreateFavoriteDoc'; export * from './useDeleteFavoriteDoc'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx similarity index 57% rename from src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx rename to src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx index b9f774a81e..dd20378309 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx @@ -2,16 +2,16 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc, KEY_LIST_DOC } from '../../doc-management'; +import { Doc, KEY_LIST_DOC } from '..'; -export type CreateDocParam = Pick & { +export type CreateChildDocParam = Pick & { parentId: string; }; -export const createDocChildren = async ({ +export const createChildDoc = async ({ title, parentId, -}: CreateDocParam): Promise => { +}: CreateChildDocParam): Promise => { const response = await fetchAPI(`documents/${parentId}/children/`, { method: 'POST', body: JSON.stringify({ @@ -26,19 +26,19 @@ export const createDocChildren = async ({ return response.json() as Promise; }; -interface CreateDocProps { - onSuccess: (data: Doc) => void; +interface UseCreateChildDocProps { + onSuccess: (doc: Doc) => void; } -export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { +export function useCreateChildDoc({ onSuccess }: UseCreateChildDocProps) { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createDocChildren, - onSuccess: (data) => { + return useMutation({ + mutationFn: createChildDoc, + onSuccess: (doc) => { void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC], }); - onSuccess(data); + onSuccess(doc); }, }); } diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/assets/pinned-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg similarity index 100% rename from src/frontend/apps/impress/src/features/docs/docs-grid/assets/pinned-document.svg rename to src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/assets/simple-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg similarity index 100% rename from src/frontend/apps/impress/src/features/docs/docs-grid/assets/simple-document.svg rename to src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx similarity index 100% rename from src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx rename to src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts index 639460cd1a..50da09e9fc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts @@ -1 +1,2 @@ export * from './ModalRemoveDoc'; +export * from './SimpleDocItem'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index adf2d777ee..930e2d4984 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useCollaboration'; export * from './useCopyDocLink'; +export * from './useCreateChildDocTree'; export * from './useDocUtils'; export * from './useIsCollaborativeEditable'; export * from './useTrans'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCreateChildDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCreateChildDocTree.tsx new file mode 100644 index 0000000000..b11a3d3453 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useCreateChildDocTree.tsx @@ -0,0 +1,35 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; + +import { useCreateChildDoc } from '../api'; +import { Doc } from '../types'; + +export const useCreateChildDocTree = (parentId?: string) => { + const treeContext = useTreeContext(); + const router = useRouter(); + + const { mutate: createChildDoc } = useCreateChildDoc({ + onSuccess: (createdDoc) => { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: parentId ?? undefined, + }; + treeContext?.treeData.addChild(parentId || null, newDoc); + + router.push(`/docs/${newDoc.id}`); + treeContext?.treeData.setSelectedNode(createdDoc); + }, + }); + + return () => { + if (!parentId) { + return null; + } + + createChildDoc({ + parentId, + }); + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx index 7d74140767..59b9b47b5f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx @@ -1,7 +1,6 @@ import { Box, Icon } from '@/components'; import { QuickSearchItemContent } from '@/components/quick-search/'; -import { Doc } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid/'; +import { Doc, SimpleDocItem } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; type DocSearchItemProps = { diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index b013708264..df3874dbc1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -18,6 +18,7 @@ import { DocSearchFiltersValues, DocSearchTarget, } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; import { DocSearchSubPageContent } from './DocSearchSubPageContent'; type DocSearchModalGlobalProps = { @@ -116,6 +117,7 @@ const DocSearchModalGlobal = ({ filters={filters} onSelect={handleSelect} onLoadingChange={setLoading} + renderElement={(doc) => } /> )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx index 4d93a12dc5..7d82005f63 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx @@ -1,21 +1,19 @@ import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { t } from 'i18next'; -import { useEffect, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; - -import { Doc } from '../../doc-management'; -import { useInfiniteSubDocs } from '../../doc-management/api/useSubDocs'; +import { Doc, useInfiniteSubDocs } from '@/docs/doc-management'; import { DocSearchFiltersValues } from './DocSearchFilters'; -import { DocSearchItem } from './DocSearchItem'; type DocSearchSubPageContentProps = { search: string; filters: DocSearchFiltersValues; onSelect: (doc: Doc) => void; onLoadingChange?: (loading: boolean) => void; + renderElement: (doc: Doc) => React.ReactNode; }; export const DocSearchSubPageContent = ({ @@ -23,6 +21,7 @@ export const DocSearchSubPageContent = ({ filters, onSelect, onLoadingChange, + renderElement, }: DocSearchSubPageContentProps) => { const treeContext = useTreeContext(); @@ -33,16 +32,30 @@ export const DocSearchSubPageContent = ({ isLoading, fetchNextPage: subDocsFetchNextPage, hasNextPage: subDocsHasNextPage, - } = useInfiniteSubDocs({ - page: 1, - title: search, - ...filters, - parent_id: treeContext?.root?.id ?? '', + } = useInfiniteSubDocs( + { + page: 1, + title: search, + ...filters, + parent_id: treeContext?.root?.id ?? '', + }, + { + enabled: !!treeContext?.root?.id, + }, + ); + const [docsData, setDocsData] = useState>({ + groupName: '', + elements: [], + emptyString: '', }); const loading = isFetching || isRefetching || isLoading; - const docsData: QuickSearchData = useMemo(() => { + useEffect(() => { + if (loading) { + return; + } + const subDocs = subDocsData?.pages.flatMap((page) => page.results) || []; if (treeContext?.root) { @@ -55,10 +68,10 @@ export const DocSearchSubPageContent = ({ } } - return { - groupName: subDocs.length > 0 ? t('Select a page') : '', + setDocsData({ + groupName: subDocs.length > 0 ? t('Select a doc') : '', elements: search ? subDocs : [], - emptyString: t('No document found'), + emptyString: search ? t('No document found') : t('Search by title'), endActions: subDocsHasNextPage ? [ { @@ -66,8 +79,9 @@ export const DocSearchSubPageContent = ({ }, ] : [], - }; + }); }, [ + loading, search, subDocsData?.pages, subDocsFetchNextPage, @@ -83,7 +97,7 @@ export const DocSearchSubPageContent = ({ } + renderElement={renderElement} /> ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index 1a0889239d..d5d0e0c444 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1,2 +1,3 @@ export * from './DocSearchModal'; export * from './DocSearchFilters'; +export * from './DocSearchSubPageContent'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts index 584ed8989e..44949aaa71 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -1,4 +1,3 @@ -export * from './useCreateChildren'; export * from './useDocChildren'; export * from './useDocTree'; export * from './useMove'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index 72b7290037..841e3f11b1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -11,8 +11,7 @@ import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid'; +import { Doc, SimpleDocItem } from '@/docs/doc-management'; import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree'; import { useMoveDoc } from '../api/useMove'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 6f3011844c..dc851b4ee8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -14,10 +14,10 @@ import { ModalRemoveDoc, Role, useCopyDocLink, + useCreateChildDoc, useDuplicateDoc, } from '@/docs/doc-management'; -import { useCreateChildrenDoc } from '../api/useCreateChildren'; import { useDetachDoc } from '../api/useDetach'; import MoveDocIcon from '../assets/doc-extract-bold.svg'; @@ -118,7 +118,7 @@ export const DocTreeItemActions = ({ }, ]; - const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + const { mutate: createChildDoc } = useCreateChildDoc({ onSuccess: (newDoc) => { onCreateSuccess?.(newDoc); void router.push(`/docs/${newDoc.id}`); @@ -174,7 +174,7 @@ export const DocTreeItemActions = ({ e.stopPropagation(); e.preventDefault(); - createChildrenDoc({ + createChildDoc({ parentId: doc.id, }); }} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx index 722d5e7af9..e84c4a2bad 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx @@ -5,7 +5,7 @@ import { css } from 'styled-components'; import { Box, Icon, StyledLink, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, LinkReach } from '@/docs/doc-management'; +import { Doc, LinkReach, SimpleDocItem } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; import { useResponsiveStore } from '@/stores'; @@ -13,11 +13,12 @@ import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; import { DocsGridActions } from './DocsGridActions'; import { DocsGridItemSharedButton } from './DocsGridItemSharedButton'; -import { SimpleDocItem } from './SimpleDocItem'; + type DocsGridItemProps = { doc: Doc; dragMode?: boolean; }; + export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts index a017747f90..540d636ccf 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts @@ -1,4 +1,3 @@ export * from './DocsGrid'; export * from './DocsGridActions'; -export * from './SimpleDocItem'; export * from './DocsGridLoader'; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx index 32c74d6e7e..dc424eb92f 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx @@ -3,9 +3,9 @@ import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc } from '@/docs/doc-management'; +import { Doc, SimpleDocItem } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; -import { DocsGridActions, SimpleDocItem } from '@/docs/docs-grid'; +import { DocsGridActions } from '@/docs/docs-grid'; import { useResponsiveStore } from '@/stores'; type LeftPanelFavoriteItemProps = {