🐛(frontend) fix position interlinking when lost focus

When switching between a interlinking search to a
interlinking link, we could lose the position of
the interlinking. The interlinking was added at
the beginning of the document or where the cursor was.
We refactorize the interlinking to be only one type
of inline content, by doing so we do not lose the position
of the interlinking because we don't remove the interlinking search
to add the interlinking link, we just update the
interlinking search to be a interlinking link.
This commit is contained in:
Anthony LC 2026-04-16 15:27:30 +02:00
parent c20e71e21d
commit 3cc9655574
No known key found for this signature in database
14 changed files with 313 additions and 343 deletions

View file

@ -730,7 +730,7 @@ test.describe('Doc Editor', () => {
await page.getByText('Link a doc').first().click();
const input = page.locator(
"span[data-inline-content-type='interlinkingSearchInline'] input",
"span[data-inline-content-type='interlinkingLinkInline'] input",
);
const searchContainer = page.locator('.quick-search-container');

View file

@ -53,10 +53,7 @@ const AIMenu = BlockNoteAI?.AIMenu;
const AIMenuController = BlockNoteAI?.AIMenuController;
const useAI = BlockNoteAI?.useAI;
const localesBNAI = BlockNoteAI?.localesAI || {};
import {
InterlinkingLinkInlineContent,
InterlinkingSearchInlineContent,
} from './custom-inline-content';
import { InterlinkingLinkInlineContent } from './custom-inline-content';
import XLMultiColumn from './xl-multi-column';
const localesBNMultiColumn = XLMultiColumn?.locales;
@ -74,7 +71,6 @@ const baseBlockNoteSchema = withPageBreak(
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
interlinkingSearchInline: InterlinkingSearchInlineContent,
interlinkingLinkInline: InterlinkingLinkInlineContent,
},
}),

View file

@ -1,25 +1,57 @@
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
} from '@blocknote/core';
import { StyleSchema } from '@blocknote/core';
import { createReactInlineContentSpec } from '@blocknote/react';
import * as Sentry from '@sentry/nextjs';
import { useRouter } from 'next/router';
import { TFunction } from 'i18next';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { validate as uuidValidate } from 'uuid';
import { Box, BoxButton, Text } from '@/components';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
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';
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
import { LinkSelected } from './LinkSelected';
import { SearchPage } from './SearchPage';
export type InterlinkingLinkInlineContentType = {
type: 'interlinkingLinkInline';
propSchema: {
disabled?: {
default: false;
values: [true, false];
};
docId?: {
default: '';
};
trigger?: {
default: '/';
values: readonly ['/', '@'];
};
title?: {
default: '';
};
};
content: 'none';
};
export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
InterlinkingLinkInlineContentType,
StyleSchema
>(
{
type: 'interlinkingLinkInline',
propSchema: {
docId: {
default: '',
},
disabled: {
default: false,
values: [true, false],
},
trigger: {
default: '/',
values: ['/', '@'],
},
title: {
default: '',
},
@ -27,189 +59,126 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
content: 'none',
},
{
render: ({ editor, inlineContent, updateInlineContent }) => {
if (!inlineContent.props.docId) {
/**
* Can have 3 render states:
* 1. Disabled state: when the inline content is disabled, it renders nothing
* 2. Search state: when the inline content has no docId, it renders the search page
* 3. Linked state: when the inline content has a docId and title, it renders the linked doc
*
* Info: We keep everything in the same inline content to easily preserve
* the element position when switching between states
*/
render: (props) => {
const { disabled, docId, title } = props.inlineContent.props;
if (disabled) {
return null;
}
/**
* Should not happen
*/
if (!uuidValidate(inlineContent.props.docId)) {
Sentry.captureException(
new Error(`Invalid docId: ${inlineContent.props.docId}`),
{
extra: { info: 'InterlinkingLinkInlineContent' },
},
if (docId && title) {
/**
* Should not happen
*/
if (!uuidValidate(docId)) {
return (
<DisableInvalidInterlink
docId={docId}
onUpdateInlineContent={() => {
props.updateInlineContent({
type: 'interlinkingLinkInline',
props: {
disabled: true,
},
});
}}
/>
);
}
return (
<LinkSelected
docId={docId}
title={title}
isEditable={props.editor.isEditable}
onUpdateTitle={(newTitle) =>
props.updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: docId,
title: newTitle,
trigger: props.inlineContent.props.trigger,
disabled: false,
},
})
}
/>
);
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId: '',
title: '',
},
});
return null;
}
return (
<LinkSelected
docId={inlineContent.props.docId}
title={inlineContent.props.title}
isEditable={editor.isEditable}
updateInlineContent={updateInlineContent}
/>
);
return <SearchPage {...props} />;
},
},
);
interface LinkSelectedProps {
docId: string;
title: string;
isEditable: boolean;
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
readonly type: 'interlinkingLinkInline';
readonly propSchema: {
readonly docId: {
readonly default: '';
};
readonly title: {
readonly default: '';
};
};
readonly content: 'none';
},
StyleSchema
>,
) => void;
}
export const LinkSelected = ({
docId,
title,
isEditable,
updateInlineContent,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId, withoutContent: true });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
updateInlineContent({
type: 'interlinkingLinkInline',
props: {
docId,
title: doc.title,
export const getInterlinkinghMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
createPage: () => void,
) => [
{
key: 'link-doc',
title: t('Link a doc'),
onItemClick: () => {
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
props: {
trigger: '/',
},
},
});
}
]);
},
aliases: ['interlinking', 'link', 'anchor', 'a'],
group,
icon: <LinkPageIcon />,
subtext: t('Link this doc to another doc'),
},
{
key: 'new-sub-doc',
title: t('New sub-doc'),
onItemClick: createPage,
aliases: ['new sub-doc'],
group,
icon: <AddPageIcon />,
subtext: t('Create a new sub-doc'),
},
];
/**
* When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
const router = useRouter();
const href = `/docs/${docId}/`;
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
if (e.metaKey || e.ctrlKey || e.shiftKey) {
window.open(href, '_blank');
return;
}
void router.push(href);
};
// This triggers on middle-mouse click
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 1) {
return;
}
e.preventDefault();
e.stopPropagation();
window.open(href, '_blank');
};
export const useGetInterlinkingMenuItems = () => {
const { currentDoc } = useDocStore();
const createChildDoc = useCreateChildDocTree(currentDoc?.id);
return (
<BoxButton
as="span"
className="--docs--interlinking-link-inline-content"
onClick={handleClick}
onAuxClick={handleAuxClick}
draggable="false"
$height="28px"
$css={css`
display: inline;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
position: relative;
top: 2px;
margin-right: 0.2rem;
}
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
transition: background-color var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
.--docs--doc-deleted & {
pointer-events: none;
}
`}
>
{emoji ? (
<Text $size="16px">{emoji}</Text>
) : (
<SelectedPageIcon
width={11.5}
color="var(--c--contextuals--content--semantic--brand--tertiary)"
/>
)}
<Text
$weight="500"
spellCheck="false"
$size="16px"
$display="inline"
$position="relative"
$css={css`
margin-left: 2px;
`}
>
<Box
className="--docs-interlinking-underline"
as="span"
$height="1px"
$width="100%"
$background="var(--c--contextuals--border--semantic--neutral--tertiary)"
$position="absolute"
$hasTransition
$radius="2px"
$css={css`
left: 0;
bottom: 0px;
`}
/>
<Box as="span" $zIndex="1" $position="relative">
{titleWithoutEmoji}
</Box>
</Text>
</BoxButton>
);
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
) => getInterlinkinghMenuItems(editor, t, t('Links'), createChildDoc);
};
const DisableInvalidInterlink = ({
docId,
onUpdateInlineContent,
}: {
docId: string;
onUpdateInlineContent: () => void;
}) => {
useEffect(() => {
Sentry.captureException(new Error(`Invalid docId: ${docId}`), {
extra: { info: 'InterlinkingInlineContent' },
});
onUpdateInlineContent();
}, [docId, onUpdateInlineContent]);
return null;
};

View file

@ -1,87 +0,0 @@
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 (
<SearchPage
{...props}
trigger={props.inlineContent.props.trigger}
contentRef={props.contentRef}
/>
);
},
},
);
export const getInterlinkinghMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
createPage: () => void,
) => [
{
key: 'link-doc',
title: t('Link a doc'),
onItemClick: () => {
editor.insertInlineContent([
{
type: 'interlinkingSearchInline',
props: {
disabled: false,
trigger: '/',
},
},
]);
},
aliases: ['interlinking', 'link', 'anchor', 'a'],
group,
icon: <LinkPageIcon />,
subtext: t('Link this doc to another doc'),
},
{
key: 'new-sub-doc',
title: t('New sub-doc'),
onItemClick: createPage,
aliases: ['new sub-doc'],
group,
icon: <AddPageIcon />,
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);
};

View file

@ -0,0 +1,133 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Text } from '@/components';
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/';
interface LinkSelectedProps {
docId: string;
title: string;
isEditable: boolean;
onUpdateTitle: (title: string) => void;
}
export const LinkSelected = ({
docId,
title,
isEditable,
onUpdateTitle,
}: LinkSelectedProps) => {
const { data: doc } = useDoc({ id: docId, withoutContent: true });
/**
* Update the content title if the referenced doc title changes
*/
useEffect(() => {
if (isEditable && doc?.title && doc.title !== title) {
onUpdateTitle(doc.title);
}
/**
* When doing collaborative editing, doc?.title might be out of sync
* causing an infinite loop of updates.
* To prevent this, we only run this effect when doc?.title changes,
* not when inlineContent.props.title changes.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doc?.title, docId, isEditable]);
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
const router = useRouter();
const href = `/docs/${docId}/`;
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
// If ctrl or command is pressed, it opens a new tab. If shift is pressed, it opens a new window
if (e.metaKey || e.ctrlKey || e.shiftKey) {
window.open(href, '_blank');
return;
}
void router.push(href);
};
// This triggers on middle-mouse click
const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 1) {
return;
}
e.preventDefault();
e.stopPropagation();
window.open(href, '_blank');
};
return (
<BoxButton
as="span"
className="--docs--interlinking-link-inline-content"
onClick={handleClick}
onAuxClick={handleAuxClick}
draggable="false"
$height="28px"
$css={css`
display: inline;
padding: 0.1rem 0.4rem;
border-radius: 4px;
& svg {
position: relative;
top: 2px;
margin-right: 0.2rem;
}
&:hover {
background-color: var(
--c--contextuals--background--semantic--contextual--primary
);
}
transition: background-color var(--c--globals--transitions--duration)
var(--c--globals--transitions--ease-out);
.--docs--doc-deleted & {
pointer-events: none;
}
`}
>
{emoji ? (
<Text $size="16px">{emoji}</Text>
) : (
<SelectedPageIcon
width={11.5}
color="var(--c--contextuals--content--semantic--brand--tertiary)"
/>
)}
<Text
$weight="500"
spellCheck="false"
$size="16px"
$display="inline"
$position="relative"
$css={css`
margin-left: 2px;
`}
>
<Box
className="--docs-interlinking-underline"
as="span"
$height="1px"
$width="100%"
$background="var(--c--contextuals--border--semantic--neutral--tertiary)"
$position="absolute"
$hasTransition
$radius="2px"
$css={css`
left: 0;
bottom: 0px;
`}
/>
<Box as="span" $zIndex="1" $position="relative">
{titleWithoutEmoji}
</Box>
</Text>
</BoxButton>
);
};

View file

@ -1,8 +1,5 @@
import {
PartialCustomInlineContentFromConfig,
StyleSchema,
} from '@blocknote/core';
import { useBlockNoteEditor } from '@blocknote/react';
import { StyleSchema } from '@blocknote/core';
import { ReactCustomInlineContentRenderProps } from '@blocknote/react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Popover } from '@mantine/core';
import type { KeyboardEvent } from 'react';
@ -18,16 +15,14 @@ import {
QuickSearchItemContent,
Text,
} from '@/components';
import {
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema,
} from '@/docs/doc-editor';
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { Doc, getEmojiAndTitle, useTrans } from '@/docs/doc-management';
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
import { useResponsiveStore } from '@/stores';
import { InterlinkingLinkInlineContentType } from './InterlinkingLinkInlineContent';
const inputStyle = css`
background-color: transparent;
border: none;
@ -38,40 +33,18 @@ const inputStyle = css`
font-family: 'Inter';
`;
type SearchPageProps = {
trigger: '/' | '@';
updateInlineContent: (
update: PartialCustomInlineContentFromConfig<
{
type: 'interlinkingSearchInline';
propSchema: {
disabled: {
default: false;
values: [true, false];
};
trigger: {
default: '/';
values: ['/', '@'];
};
};
content: 'styled';
},
StyleSchema
>,
) => void;
contentRef: (node: HTMLElement | null) => void;
};
type ReactInterlinkingSearch = ReactCustomInlineContentRenderProps<
InterlinkingLinkInlineContentType,
StyleSchema
>;
export const SearchPage = ({
contentRef,
trigger,
updateInlineContent,
}: SearchPageProps) => {
const editor = useBlockNoteEditor<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>();
editor,
inlineContent,
}: ReactInterlinkingSearch) => {
const trigger = inlineContent.props.trigger;
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
@ -107,16 +80,19 @@ export const SearchPage = ({
}
updateInlineContent({
type: 'interlinkingSearchInline',
type: 'interlinkingLinkInline',
props: {
disabled: true,
trigger,
},
});
contentRef(null);
editor.focus();
editor.insertInlineContent([insertContent]);
if (insertContent) {
contentRef(null);
editor.focus();
(editor as DocsBlockNoteEditor).insertInlineContent([insertContent]);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
@ -171,7 +147,7 @@ export const SearchPage = ({
tabIndex={-1} // Ensure the span is focusable
>
{' '}
<Box as="span" aria-hidden="true">
<Box as="span" aria-hidden="true" $height="25px">
{trigger}
</Box>
<Box
@ -282,25 +258,14 @@ export const SearchPage = ({
}
updateInlineContent({
type: 'interlinkingSearchInline',
type: 'interlinkingLinkInline',
props: {
disabled: true,
trigger,
docId: doc.id,
title: doc.title || untitledDocument,
},
});
contentRef(null);
editor.insertInlineContent([
{
type: 'interlinkingLinkInline',
props: {
docId: doc.id,
title: doc.title || untitledDocument,
},
},
]);
editor.focus();
}}
renderSearchElement={(doc) => {

View file

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

View file

@ -99,9 +99,8 @@ export const useShortcuts = (
event.preventDefault();
editor.insertInlineContent([
{
type: 'interlinkingSearchInline',
type: 'interlinkingLinkInline',
props: {
disabled: false,
trigger: '@',
},
},

View file

@ -6,7 +6,7 @@ import { DocsExporterDocx } from '../types';
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
if (!inline.props.docId || !inline.props.title || inline.props.disabled) {
return new TextRun('');
}

View file

@ -6,7 +6,7 @@ import { DocsExporterODT } from '../types';
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
if (!inline.props.docId || !inline.props.title || inline.props.disabled) {
return null;
}

View file

@ -7,7 +7,7 @@ import { DocsExporterPDF } from '../types';
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
(inline) => {
if (!inline.props.docId) {
if (!inline.props.docId || !inline.props.title || inline.props.disabled) {
return <></>;
}

View file

@ -1,5 +1,4 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import { TextRun } from 'docx';
import {
blockMappingCalloutDocx,
@ -48,7 +47,6 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
},
inlineContentMapping: {
...docxDefaultSchemaMappings.inlineContentMapping,
interlinkingSearchInline: () => new TextRun(''),
interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx,
},
styleMapping: {

View file

@ -27,7 +27,6 @@ export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
inlineContentMapping: {
...baseInlineMappings,
interlinkingSearchInline: () => null,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
},
};

View file

@ -30,7 +30,6 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
},
inlineContentMapping: {
...pdfDefaultSchemaMappings.inlineContentMapping,
interlinkingSearchInline: () => <></>,
interlinkingLinkInline: inlineContentMappingInterlinkingLinkPDF,
},
styleMapping: {