From 155e7dfe2276b9f6e070df175a904574b4b0297b Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 23 Apr 2025 18:41:11 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20interlinking=20custom=20i?= =?UTF-8?q?nline=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to be able to interlink documents in the editor. We created a custom inline content that allows users to interlink documents. --- CHANGELOG.md | 1 + .../__tests__/app-impress/doc-editor.spec.ts | 55 ++++ .../app-impress/doc-grid-dnd.spec.ts | 2 +- .../e2e/__tests__/app-impress/utils-common.ts | 6 - .../apps/impress/src/components/Box.tsx | 9 +- .../impress/src/components/DropdownMenu.tsx | 4 +- .../apps/impress/src/components/index.ts | 1 + .../components/quick-search/QuickSearch.tsx | 46 +++- .../quick-search/QuickSearchGroup.tsx | 11 +- .../quick-search/QuickSearchStyle.tsx | 237 ++++++++-------- .../docs/doc-editor/assets/doc-link.svg | 14 + .../docs/doc-editor/assets/doc-selected.svg | 6 + .../doc-editor/components/BlockNoteEditor.tsx | 10 + .../components/BlockNoteSuggestionMenu.tsx | 2 +- .../InterlinkingLinkInlineContent.tsx | 79 ++++++ .../InterlinkingSearchInlineContent.tsx | 49 +++- .../Interlinking/SearchPage.tsx | 256 ++++++++++++++++++ .../Interlinking/index.ts | 1 + .../doc-search/components/DocSearchModal.tsx | 2 + .../components/DocSearchSubPageContent.tsx | 46 ++-- .../docs/doc-search/components/index.ts | 1 + 21 files changed, 685 insertions(+), 153 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1f54c1..3ce60495 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-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 7a450c1a..8ae32f08 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,59 @@ 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); + }); }); 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 ba09d36e..68ed84d9 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/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 49445f38..dc3c412e 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 84e32057..448a0090 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/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index fcbd9ebe..5700aafe 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 a0f256f5..bb426724 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 27887927..9c0ad62f 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 58aca88d..766db7f1 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` + & *:focus-visible { + outline: none; + } + .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; - outline: none; - color: var(--c--theme--colors--greyscale-1000); - border-radius: 0; - - &::placeholder { - color: var(--c--theme--colors--greyscale-500); - } - } - - [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; + width: 100%; + background: #ffffff; + border-radius: 12px; + overflow: hidden; + transition: transform 100ms ease; + outline: none; } - &:hover, - &[data-selected='true'] { - background: var(--c--theme--colors--greyscale-100); - .show-right-on-focus { - opacity: 1; + [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); } } - &[data-disabled='true'] { - color: var(--c--theme--colors--greyscale-500); - cursor: not-allowed; - } - - & + [cmdk-item] { - margin-top: 4px; - } - } - - [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; + [cmdk-item] { + content-visibility: auto; + cursor: pointer; + border-radius: var(--c--theme--spacings--xs); + font-size: 14px; + display: flex; align-items: center; - justify-content: center; - text-transform: uppercase; + gap: 8px; + user-select: none; + will-change: background, color; + transition: all 150ms ease; + transition-property: none; + + .show-right-on-focus { + 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; + } + + & + [cmdk-item] { + margin-top: 4px; + } + } + + [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-separator] { + height: 1px; + width: 100%; + background: var(--c--theme--colors--greyscale-500); + margin: 4px 0; + } + + *: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; + + display: flex; + align-items: center; + margin-bottom: var(--c--theme--spacings--xs); + } + + [cmdk-empty] { } } - [cmdk-separator] { - height: 1px; - width: 100%; - background: var(--c--theme--colors--greyscale-500); - margin: 4px 0; + .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__title { + font-size: var(--c--theme--font--sizes--xs); + padding: var(--c--theme--spacings--base); + margin-bottom: 0; + } } - - *: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; - - display: flex; - align-items: center; - margin-bottom: var(--c--theme--spacings--xs); - } - - [cmdk-empty] { - } -} - -.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__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-link.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg new file mode 100644 index 00000000..bcb7b881 --- /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-selected.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg new file mode 100644 index 00000000..f36a74af --- /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 6709e97c..71b6c00d 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'; @@ -28,6 +29,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 +46,11 @@ const baseBlockNoteSchema = withPageBreak( callout: CalloutBlock, divider: DividerBlock, }, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + interlinkingSearchInline: InterlinkingSearchInlineContent, + interlinkingLinkInline: InterlinkingLinkInlineContent, + }, }), ); 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 450a3f52..4e8c6e30 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 @@ -43,7 +43,7 @@ export const BlockNoteSuggestionMenu = () => { ); const newSlashMenuItems = [ ...defaultMenu.slice(0, index + 1), - ...getInterlinkingMenuItems(t), + ...getInterlinkingMenuItems(editor, t), ...defaultMenu.slice(index + 1), ]; 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 new file mode 100644 index 00000000..778422cc --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { createReactInlineContentSpec } from '@blocknote/react'; +import { useEffect } from 'react'; +import { css } from 'styled-components'; + +import { StyledLink, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg'; +import { useDoc } from '@/docs/doc-management'; + +export const InterlinkingLinkInlineContent = createReactInlineContentSpec( + { + type: 'interlinkingLinkInline', + propSchema: { + url: { + default: '', + }, + docId: { + default: '', + }, + title: { + default: '', + }, + }, + content: 'none', + }, + { + render: ({ inlineContent, updateInlineContent }) => { + 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 index c90e5bcb..2ac6fa90 100644 --- 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 @@ -1,16 +1,62 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { createReactInlineContentSpec } from '@blocknote/react'; import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { TFunction } from 'i18next'; import { useRouter } from 'next/navigation'; import { useCallback } from 'react'; +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 { Doc, useCreateChildDoc, useDocStore } from '@/docs/doc-management'; +import { SearchPage } from './SearchPage'; + +export const InterlinkingSearchInlineContent = createReactInlineContentSpec( + { + type: 'interlinkingSearchInline', + propSchema: { + disabled: { + default: false, + values: [true, false], + }, + }, + content: 'styled', + }, + { + render: (props) => { + if (props.inlineContent.props.disabled) { + return null; + } + + return ; + }, + }, +); + export const getInterlinkingMenuItems = ( + editor: DocsBlockNoteEditor, t: TFunction<'translation', undefined>, group: string, createPage: () => void, ) => [ + { + title: t('Link a doc'), + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'interlinkingSearchInline', + props: { + disabled: false, + }, + }, + ]); + }, + aliases: ['interlinking', 'link', 'anchor', 'a'], + group, + icon: , + subtext: t('Link this doc to another doc'), + }, { title: t('New sub-doc'), onItemClick: createPage, @@ -42,8 +88,9 @@ export const useGetInterlinkingMenuItems = () => { }); return useCallback( - (t: TFunction<'translation', undefined>) => + (editor: DocsBlockNoteEditor, t: TFunction<'translation', undefined>) => getInterlinkingMenuItems( + editor, t, t('Links'), () => 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 00000000..b61c3e33 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -0,0 +1,256 @@ +/* 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 { css } from 'styled-components'; + +import { + Box, + Card, + Icon, + QuickSearch, + 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 { 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 = { + updateInlineContent: ( + update: PartialCustomInlineContentFromConfig< + { + type: string; + propSchema: { + disabled: { + default: boolean; + }; + }; + content: 'styled'; + }, + StyleSchema + >, + ) => void; + contentRef: (node: HTMLElement | null) => void; +}; + +export const SearchPage = ({ + contentRef, + updateInlineContent, +}: SearchPageProps) => { + const { colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + 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 ( + + + {' '} + / + { + 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, + }, + }); + + 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, + }, + }); + + editor.insertInlineContent([ + { + type: 'interlinkingLinkInline', + props: { + url: `/docs/${doc.id}`, + docId: doc.id, + title: doc.title || untitledDocument, + }, + }, + ]); + + editor.focus(); + }} + renderElement={(doc) => ( + + + + {doc.title} + + + } + right={ + + } + /> + )} + /> + + + + + ); +}; 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 b1a3b448..3fabd144 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 './InterlinkingSearchInlineContent'; 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 b0137082..df3874db 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 4d93a12d..7d82005f 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 1a088923..d5d0e0c4 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';