(frontend) interlinking custom inline content

We want to be able to interlink documents in the editor.
We created a custom inline content that allows
users to interlink documents.
This commit is contained in:
Anthony LC
2025-04-23 18:41:11 +02:00
parent afa48b6675
commit 155e7dfe22
21 changed files with 685 additions and 153 deletions

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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' })

View File

@@ -16,6 +16,7 @@ export interface BoxProps {
$background?: CSSProperties['background'];
$color?: CSSProperties['color'];
$css?: string | RuleSet<object>;
$cursor?: CSSProperties['cursor'];
$direction?: CSSProperties['flexDirection'];
$display?: CSSProperties['display'];
$effect?: 'show' | 'hide';
@@ -44,13 +45,13 @@ export interface BoxProps {
export type BoxType = ComponentPropsWithRef<typeof Box>;
export const Box = styled('div')<BoxProps>`
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};`}

View File

@@ -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<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(opened ?? false);
const blockButtonRef = useRef<HTMLDivElement>(null);
const onOpenChange = (isOpen: boolean) => {

View File

@@ -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';

View File

@@ -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<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [selectedValue, setSelectedValue] = useState<string>('');
// 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 (
<>
<QuickSearchStyle />
<div className="quick-search-container">
<Command label={label} shouldFilter={false} ref={ref}>
<Command
label={label}
shouldFilter={false}
ref={ref}
value={selectedValue}
onValueChange={setSelectedValue}
tabIndex={0}
>
{showInput && (
<QuickSearchInput
loading={loading}

View File

@@ -1,7 +1,7 @@
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { Box } from '../Box';
import { Box, Text } from '@/components';
import { QuickSearchData } from './QuickSearch';
import { QuickSearchItem } from './QuickSearchItem';
@@ -23,6 +23,7 @@ export const QuickSearchGroup = <T,>({
key={group.groupName}
heading={group.groupName}
forceMount={false}
contentEditable={false}
>
{group.startActions?.map((action, index) => {
return (
@@ -58,7 +59,13 @@ export const QuickSearchGroup = <T,>({
);
})}
{group.emptyString && group.elements.length === 0 && (
<span className="ml-b clr-greyscale-500">{group.emptyString}</span>
<Text
$variation="500"
$margin={{ left: '2xs', bottom: '3xs' }}
$size="sm"
>
{group.emptyString}
</Text>
)}
</Command.Group>
</Box>

View File

@@ -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;
}
}
`;

View File

@@ -0,0 +1,14 @@
<svg
width="24"
height="25"
viewBox="0 0 24 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.709 15.8262C21.202 15.8262 21.5875 15.9577 21.8574 16.2275L21.9521 16.333C22.1583 16.592 22.2588 16.939 22.2588 17.3682V22.5098C22.2588 22.9389 22.1583 23.286 21.9521 23.5449L21.8574 23.6504C21.5875 23.9202 21.202 24.0517 20.709 24.0518H15.584C15.1523 24.0518 14.8024 23.9513 14.541 23.7451L14.4346 23.6504C14.1647 23.3833 14.0332 23 14.0332 22.5098V17.3682C14.0332 16.8779 14.1646 16.4947 14.4346 16.2275L14.541 16.1328C14.8024 15.9267 15.1523 15.8262 15.584 15.8262H20.709ZM17.4443 0.961914C18.5273 0.961914 19.3662 1.23768 19.9307 1.81641L20.0342 1.92773C20.5341 2.50088 20.7734 3.30971 20.7734 4.33105V13.6318C20.7734 14.1429 20.3587 14.5576 19.8477 14.5576C19.3367 14.5574 18.9229 14.1428 18.9229 13.6318V4.3623C18.9229 3.85862 18.7884 3.48005 18.5273 3.21875L18.5264 3.21777C18.2723 2.95072 17.8871 2.8125 17.3623 2.8125H6.63672C6.17751 2.81256 5.82275 2.91826 5.56641 3.12402L5.46289 3.21777C5.20816 3.47904 5.07715 3.85807 5.07715 4.3623V19.8555C5.07715 20.336 5.19802 20.7014 5.42969 20.9609L5.46289 20.9287L5.49805 20.8936L5.5332 20.9297H5.53418L5.56934 20.9658L5.5332 21.001L5.5 21.0332C5.76013 21.2713 6.13568 21.3954 6.63672 21.3955H11.625C12.1358 21.3957 12.5496 21.8095 12.5498 22.3203C12.5498 22.8313 12.1359 23.2459 11.625 23.2461H6.55469C5.53947 23.246 4.7368 23.0064 4.16992 22.5059L4.05957 22.4023C3.49544 21.8309 3.22658 20.9822 3.22656 19.8867V4.33105C3.22663 3.24207 3.49545 2.39422 4.05859 1.81641H4.05957L4.16895 1.71191C4.73581 1.20479 5.53881 0.961976 6.55469 0.961914H17.4443ZM17.3682 18.1484C17.2625 18.1485 17.1758 18.1703 17.1055 18.21L17.04 18.2559C16.9619 18.3244 16.9209 18.4175 16.9209 18.541C16.9209 18.6617 16.9604 18.7537 17.0361 18.8223L17.1006 18.8682C17.171 18.908 17.2583 18.9287 17.3643 18.9287H18.0039L18.5303 18.8613L18.6934 18.8398L18.5703 18.9482L17.9668 19.4795L16.5449 20.9004V20.9014C16.4462 20.9975 16.3985 21.1065 16.3984 21.2305C16.3984 21.3719 16.4428 21.4799 16.5273 21.5596H16.5264C16.6159 21.6414 16.723 21.6825 16.8496 21.6826C16.9174 21.6826 16.981 21.6706 17.04 21.6475L17.125 21.6025C17.1525 21.5839 17.1799 21.5613 17.2061 21.5352L18.6162 20.125L19.1416 19.5273L19.2451 19.4092L19.2285 19.5664L19.1689 20.1182V20.7168C19.169 20.8574 19.2064 20.9646 19.2754 21.0439L19.3311 21.0947C19.392 21.1378 19.4683 21.1592 19.5615 21.1592C19.6851 21.1591 19.7763 21.1186 19.8418 21.041H19.8428L19.8887 20.9756C19.9283 20.9057 19.9492 20.8201 19.9492 20.7168V18.6855C19.9492 18.5477 19.9209 18.4395 19.8682 18.3574L19.8076 18.2842C19.7144 18.196 19.5832 18.1484 19.4082 18.1484H17.3682ZM11.7949 12.7949C12.0155 12.7949 12.2056 12.87 12.3525 13.0244H12.3535C12.5057 13.1772 12.5811 13.3698 12.5811 13.5908C12.581 13.7495 12.5367 13.8931 12.4512 14.0186L12.3525 14.1377C12.2046 14.2856 12.0139 14.3564 11.7949 14.3564H8.11328C7.89437 14.3564 7.70227 14.2855 7.54883 14.1406L7.54297 14.1348C7.40022 13.9836 7.32815 13.7995 7.32812 13.5908C7.32812 13.3718 7.39806 13.1799 7.54297 13.0264L7.5459 13.0234L7.60547 12.9697C7.74785 12.8528 7.91945 12.795 8.11328 12.7949H11.7949ZM15.8965 9.21582C16.1111 9.21584 16.2969 9.29151 16.4424 9.44336L16.4961 9.5C16.6134 9.63628 16.6718 9.80257 16.6719 9.99121C16.6719 10.2068 16.5949 10.3944 16.4443 10.5459L16.4453 10.5469C16.2994 10.7008 16.1126 10.7773 15.8965 10.7773H8.11328C7.89178 10.7773 7.69883 10.7018 7.5459 10.5488V10.5479L7.54395 10.5459H7.54297C7.39907 10.3935 7.32812 10.2051 7.32812 9.99121C7.32821 9.77745 7.39921 9.59103 7.5459 9.44434L7.60547 9.39062C7.74779 9.27383 7.91955 9.2159 8.11328 9.21582H15.8965ZM15.8965 5.6377C16.1112 5.63772 16.2968 5.71321 16.4424 5.86523L16.4961 5.92188C16.6133 6.0582 16.6719 6.22441 16.6719 6.41309C16.6719 6.62878 16.595 6.81624 16.4443 6.96777L16.4453 6.96875C16.2994 7.12253 16.1125 7.19822 15.8965 7.19824H8.11328C7.89183 7.19815 7.69881 7.12357 7.5459 6.9707V6.96973L7.54297 6.9668C7.39925 6.81448 7.32815 6.62682 7.32812 6.41309C7.32812 6.19928 7.39928 6.01295 7.5459 5.86621C7.69883 5.71328 7.89178 5.63778 8.11328 5.6377H15.8965Z"
fill="#3A3A3A"
stroke="#3A3A3A"
stroke-width="0.1"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.40918 4.70117C3.28613 4.70117 3.18359 4.66016 3.10156 4.57812C3.02409 4.49609 2.98535 4.39583 2.98535 4.27734C2.98535 4.15885 3.02409 4.06087 3.10156 3.9834C3.18359 3.90137 3.28613 3.86035 3.40918 3.86035H8.59766C8.71615 3.86035 8.81413 3.90137 8.8916 3.9834C8.97363 4.06087 9.01465 4.15885 9.01465 4.27734C9.01465 4.39583 8.97363 4.49609 8.8916 4.57812C8.81413 4.66016 8.71615 4.70117 8.59766 4.70117H3.40918ZM3.40918 7.08691C3.28613 7.08691 3.18359 7.0459 3.10156 6.96387C3.02409 6.88184 2.98535 6.78158 2.98535 6.66309C2.98535 6.5446 3.02409 6.44661 3.10156 6.36914C3.18359 6.28711 3.28613 6.24609 3.40918 6.24609H8.59766C8.71615 6.24609 8.81413 6.28711 8.8916 6.36914C8.97363 6.44661 9.01465 6.5446 9.01465 6.66309C9.01465 6.78158 8.97363 6.88184 8.8916 6.96387C8.81413 7.0459 8.71615 7.08691 8.59766 7.08691H3.40918ZM3.40918 9.47266C3.28613 9.47266 3.18359 9.43392 3.10156 9.35645C3.02409 9.27441 2.98535 9.17643 2.98535 9.0625C2.98535 8.93945 3.02409 8.83691 3.10156 8.75488C3.18359 8.67285 3.28613 8.63184 3.40918 8.63184H5.86328C5.98633 8.63184 6.08659 8.67285 6.16406 8.75488C6.24609 8.83691 6.28711 8.93945 6.28711 9.0625C6.28711 9.17643 6.24609 9.27441 6.16406 9.35645C6.08659 9.43392 5.98633 9.47266 5.86328 9.47266H3.40918ZM0.250977 13.2598V2.88965C0.250977 2.17871 0.426432 1.64323 0.777344 1.2832C1.13281 0.923177 1.66374 0.743164 2.37012 0.743164H9.62988C10.3363 0.743164 10.8649 0.923177 11.2158 1.2832C11.5713 1.64323 11.749 2.17871 11.749 2.88965V13.2598C11.749 13.9753 11.5713 14.5107 11.2158 14.8662C10.8649 15.2217 10.3363 15.3994 9.62988 15.3994H2.37012C1.66374 15.3994 1.13281 15.2217 0.777344 14.8662C0.426432 14.5107 0.250977 13.9753 0.250977 13.2598ZM1.35156 13.2393C1.35156 13.5811 1.44043 13.8431 1.61816 14.0254C1.80046 14.2077 2.06934 14.2988 2.4248 14.2988H9.5752C9.93066 14.2988 10.1973 14.2077 10.375 14.0254C10.5573 13.8431 10.6484 13.5811 10.6484 13.2393V2.91016C10.6484 2.56836 10.5573 2.30632 10.375 2.12402C10.1973 1.93717 9.93066 1.84375 9.5752 1.84375H2.4248C2.06934 1.84375 1.80046 1.93717 1.61816 2.12402C1.44043 2.30632 1.35156 2.56836 1.35156 2.91016V13.2393Z"
fill="#8585F6"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -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,
},
}),
);

View File

@@ -43,7 +43,7 @@ export const BlockNoteSuggestionMenu = () => {
);
const newSlashMenuItems = [
...defaultMenu.slice(0, index + 1),
...getInterlinkingMenuItems(t),
...getInterlinkingMenuItems(editor, t),
...defaultMenu.slice(index + 1),
];

View File

@@ -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 <LinkSelected {...inlineContent.props} />;
},
},
);
interface LinkSelectedProps {
url: string;
title: string;
}
const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<StyledLink
href={url}
$css={css`
display: inline;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
position: relative;
top: 2px;
margin-right: 0.2rem;
}
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
transition: background-color 0.2s ease-in-out;
`}
>
<SelectedPageIcon width={11.5} />
<Text $weight="500" spellCheck="false" $size="16px" $display="inline">
{title}
</Text>
</StyledLink>
);
};

View File

@@ -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 <SearchPage {...props} />;
},
},
);
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: <LinkPageIcon />,
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'),
() =>

View File

@@ -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<HTMLInputElement>(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 (
<Box as="span" $position="relative">
<Box
as="span"
className="inline-content"
$background={colorsTokens['greyscale-100']}
$color="var(--c--theme--colors--greyscale-700)"
$direction="row"
$radius="3px"
$padding="1px"
$display="inline-flex"
tabIndex={-1} // Ensure the span is focusable
>
{' '}
/
<Box
as="input"
$padding={{ left: '3px' }}
$css={inputStyle}
ref={inputRef}
$display="inline-flex"
onInput={(e) => {
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();
}
}}
/>
</Box>
<Box
$minWidth={isDesktop ? '330px' : '220px'}
$width="fit-content"
$position="absolute"
$css={css`
top: 28px;
z-index: 1000;
& .quick-search-container [cmdk-root] {
border-radius: inherit;
}
`}
>
<QuickSearch showInput={false}>
<Card
$css={css`
box-shadow: 0 0 3px 0px var(--c--theme--colors--greyscale-200);
& > 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' }}
>
<DocSearchSubPageContent
search={search}
filters={{ target: DocSearchTarget.CURRENT }}
onSelect={(doc) => {
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) => (
<QuickSearchItemContent
left={
<Box
$direction="row"
$gap="0.6rem"
$align="center"
$padding={{ vertical: '0.5rem', horizontal: '0.2rem' }}
$width="100%"
>
<FoundPageIcon />
<Text
$size="14px"
$color="var(--c--theme--colors--greyscale-1000)"
spellCheck="false"
>
{doc.title}
</Text>
</Box>
}
right={
<Icon
iconName="keyboard_return"
$variation="600"
spellCheck="false"
/>
}
/>
)}
/>
</Card>
</QuickSearch>
</Box>
</Box>
);
};

View File

@@ -1 +1,2 @@
export * from './InterlinkingLinkInlineContent';
export * from './InterlinkingSearchInlineContent';

View File

@@ -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) => <DocSearchItem doc={doc} />}
/>
)}
</>

View File

@@ -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<Doc>();
@@ -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<QuickSearchData<Doc>>({
groupName: '',
elements: [],
emptyString: '',
});
const loading = isFetching || isRefetching || isLoading;
const docsData: QuickSearchData<Doc> = 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 = ({
<QuickSearchGroup
onSelect={onSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
renderElement={renderElement}
/>
);
};

View File

@@ -1,2 +1,3 @@
export * from './DocSearchModal';
export * from './DocSearchFilters';
export * from './DocSearchSubPageContent';