Skip to content

Commit 8acf462

Browse files
committed
✨(frontend) preserve interlink style on drag-and-drop in editor
adds hook to normalize dropped blocks and restore internal link format Signed-off-by: Cyril <[email protected]>
1 parent b3980e7 commit 8acf462

File tree

3 files changed

+171
-0
lines changed

3 files changed

+171
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to
4343
- 🐛(frontend) fix attachment download filename #1447
4444
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
4545
- 🔒(frontend) prevent readers from changing callout emoji #1449
46+
- 🐛(frontend) preserve interlink style on drag-and-drop in editor #1460
4647

4748
## [3.7.0] - 2025-09-12
4849

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
useUploadFile,
2727
useUploadStatus,
2828
} from '../hook';
29+
import { useInterlinkDropNormalizer } from '../hook/useInterlinkDropNormalizer';
2930
import { useEditorStore } from '../stores';
3031
import { cssEditor } from '../styles';
3132
import { DocsBlockNoteEditor } from '../types';
@@ -167,6 +168,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
167168
useHeadings(editor);
168169
useShortcuts(editor);
169170
useUploadStatus(editor);
171+
useInterlinkDropNormalizer(editor);
170172

171173
useEffect(() => {
172174
setEditor(editor);
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import type {
2+
Block as BNBlock,
3+
PartialInlineContent as BNPartialInlineContent,
4+
} from '@blocknote/core';
5+
import { useEffect } from 'react';
6+
7+
import { blockNoteSchema } from '../components/BlockNoteEditor';
8+
import type { DocsBlockNoteEditor } from '../types';
9+
10+
type InlineElement = BNPartialInlineContent<
11+
typeof blockNoteSchema.inlineContentSchema,
12+
typeof blockNoteSchema.styleSchema
13+
>[number];
14+
15+
type Change = {
16+
source?: { type?: string };
17+
block: BNBlock<
18+
typeof blockNoteSchema.blockSchema,
19+
typeof blockNoteSchema.inlineContentSchema,
20+
typeof blockNoteSchema.styleSchema
21+
>;
22+
};
23+
24+
/** Check if a given href points to an internal Docs URL. */
25+
const isInternalDocUrl = (href: string): boolean =>
26+
/^\/docs\/[^\s/?#]+/.test(href);
27+
28+
/** Type guard — detects text inline content (plain text nodes). */
29+
const isTextInline = (val: unknown): val is { type: 'text'; text: string } =>
30+
typeof val === 'object' &&
31+
val !== null &&
32+
'type' in val &&
33+
(val as { type: string }).type === 'text' &&
34+
'text' in val &&
35+
typeof (val as { text: unknown }).text === 'string';
36+
37+
/** Type guard, detects standard link inlines (that may need normalization). */
38+
const isLinkInline = (
39+
val: unknown,
40+
): val is {
41+
type: 'link';
42+
href: string;
43+
content: Array<{ type: 'text'; text: string }>;
44+
} =>
45+
typeof val === 'object' &&
46+
val !== null &&
47+
'type' in val &&
48+
(val as { type: string }).type === 'link' &&
49+
'href' in val &&
50+
typeof (val as { href: unknown }).href === 'string' &&
51+
'content' in val &&
52+
Array.isArray((val as { content: unknown }).content);
53+
54+
/** Extracts the visible text from a link inline’s nested content. */
55+
const getLinkText = (content: unknown): string => {
56+
if (typeof content === 'string') {
57+
return content;
58+
}
59+
if (Array.isArray(content)) {
60+
return content
61+
.filter(isTextInline)
62+
.map((c) => c.text)
63+
.join('');
64+
}
65+
return '';
66+
};
67+
68+
/**
69+
* Converts plain `<a>` links pointing to `/docs/:id`
70+
* into `interlinkingLinkInline` objects, preserving title and docId.
71+
*/
72+
const convertInline = (
73+
inline: InlineElement[],
74+
): { newInline: InlineElement[]; changed: boolean } => {
75+
let changed = false;
76+
77+
const newInline = inline.map((ic) => {
78+
if (isLinkInline(ic) && isInternalDocUrl(ic.href)) {
79+
const match = ic.href.match(/^\/docs\/([^\s/?#]+)/);
80+
const docId = match ? match[1] : '';
81+
const title = getLinkText(ic.content);
82+
changed = true;
83+
84+
return {
85+
type: 'interlinkingLinkInline',
86+
props: { url: ic.href, docId, title },
87+
} as InlineElement;
88+
}
89+
return ic;
90+
});
91+
92+
return { newInline, changed };
93+
};
94+
95+
/**
96+
* Recursively traverses a block tree to normalize internal links
97+
* and collect updates for the affected blocks.
98+
*/
99+
const normalizeBlock = (
100+
block: BNBlock<
101+
typeof blockNoteSchema.blockSchema,
102+
typeof blockNoteSchema.inlineContentSchema,
103+
typeof blockNoteSchema.styleSchema
104+
>,
105+
updates: Array<{ id: string; content: InlineElement[] }>,
106+
): void => {
107+
if (Array.isArray(block.content)) {
108+
const { newInline, changed } = convertInline(
109+
block.content as InlineElement[],
110+
);
111+
if (changed) {
112+
updates.push({ id: block.id, content: newInline });
113+
}
114+
}
115+
116+
if (Array.isArray(block.children)) {
117+
block.children.forEach((child) => normalizeBlock(child, updates));
118+
}
119+
};
120+
121+
/**
122+
* Normalizes dropped interlinks in the BlockNote editor.
123+
*
124+
* When a user drags and drops a block containing an internal link (/docs/:id)
125+
* into another column, BlockNote may convert it into a standard <a> link.
126+
*
127+
* This hook listens to `drop` events in the editor and restores the proper
128+
* inline type `interlinkingLinkInline` (with url, docId, and title props).
129+
*
130+
* @param editor The active DocsBlockNoteEditor instance.
131+
*/
132+
export const useInterlinkDropNormalizer = (
133+
editor: DocsBlockNoteEditor,
134+
): void => {
135+
useEffect(() => {
136+
if (!editor) {
137+
return;
138+
}
139+
140+
const unsubscribe = editor.onChange((ed, { getChanges }) => {
141+
const changes = getChanges() as Change[];
142+
143+
const isRelevantChange = (type?: string) =>
144+
type === 'drop' ||
145+
type === 'paste' ||
146+
type === 'insert' ||
147+
type === undefined;
148+
149+
if (!changes.some((c) => isRelevantChange(c.source?.type))) {
150+
return;
151+
}
152+
153+
const updates: Array<{ id: string; content: InlineElement[] }> = [];
154+
155+
changes.forEach(({ block }) => {
156+
normalizeBlock(block, updates);
157+
});
158+
159+
updates.forEach(({ id, content }) => {
160+
ed.updateBlock(id, { content });
161+
});
162+
});
163+
164+
return () => {
165+
unsubscribe?.();
166+
};
167+
}, [editor]);
168+
};

0 commit comments

Comments
 (0)