mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-09 11:17:55 +02:00
✨(y-provider) preserve custom blocks on HTML/markdown conversion
Wire the docs BlockNote schema (callout, pdf, uploadLoader, interlinking link, page break) into the conversion editor so /api/convert no longer drops or mangles these blocks.
This commit is contained in:
@@ -6,6 +6,11 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(y-provider) preserve callouts, PDFs, page breaks and
|
||||
interlinking links on HTML/markdown export #2296
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix removed item in the tree #2420
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../src/env', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { docsBlockNoteSchema } from '@/blockSpecs';
|
||||
import { initApp } from '@/servers';
|
||||
|
||||
import {
|
||||
@@ -300,6 +301,314 @@ describe('Conversion Testing', () => {
|
||||
expect(response.body).toStrictEqual(expectedBlocks);
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to HTML with callout block', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'callout' as const,
|
||||
props: { emoji: '⚠️', backgroundColor: 'yellow' },
|
||||
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/html')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('<aside');
|
||||
expect(response.text).toContain('role="note"');
|
||||
expect(response.text).toContain('data-emoji="⚠️"');
|
||||
expect(response.text).toContain('data-background-color="yellow"');
|
||||
expect(response.text).toContain('Be careful');
|
||||
// The inner emoji span is marked so downstream parsers can drop it
|
||||
// (the canonical emoji is on the <aside>).
|
||||
expect(response.text).toContain(
|
||||
'<span aria-hidden="true" data-emoji="⚠️">',
|
||||
);
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to Markdown preserves callout content', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'callout' as const,
|
||||
props: { emoji: '⚠️', backgroundColor: 'yellow' },
|
||||
content: [{ type: 'text' as const, text: 'Be careful', styles: {} }],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/markdown')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('⚠️');
|
||||
expect(response.text).toContain('Be careful');
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to Markdown preserves interlinking link', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'interlinkingLinkInline' as const,
|
||||
props: {
|
||||
docId: '00000000-0000-0000-0000-000000000123',
|
||||
title: 'Other doc',
|
||||
disabled: false,
|
||||
trigger: '/' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/markdown')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain(
|
||||
'[Other doc](/docs/00000000-0000-0000-0000-000000000123/ "Other doc")',
|
||||
);
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to HTML with PDF block', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'pdf' as const,
|
||||
props: {
|
||||
url: 'https://example.com/file.pdf',
|
||||
name: 'Annual report',
|
||||
showPreview: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/html')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('<iframe');
|
||||
expect(response.text).toContain('src="https://example.com/file.pdf"');
|
||||
expect(response.text).toContain('title="Annual report"');
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to HTML strips unsafe PDF URL schemes', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'pdf' as const,
|
||||
props: {
|
||||
url: 'javascript:alert(1)',
|
||||
name: 'Malicious',
|
||||
showPreview: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/html')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).not.toContain('<iframe');
|
||||
expect(response.text).not.toMatch(/(?:src|href)="javascript:/);
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to HTML with interlinking inline content', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'interlinkingLinkInline' as const,
|
||||
props: {
|
||||
docId: '00000000-0000-0000-0000-000000000123',
|
||||
title: 'Other doc',
|
||||
disabled: false,
|
||||
trigger: '/' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/html')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain(
|
||||
'href="/docs/00000000-0000-0000-0000-000000000123/"',
|
||||
);
|
||||
expect(response.text).toContain(
|
||||
'data-doc-id="00000000-0000-0000-0000-000000000123"',
|
||||
);
|
||||
expect(response.text).toContain('title="Other doc"');
|
||||
expect(response.text).toContain('Other doc');
|
||||
expect(response.text).not.toContain('data-inline-content-type');
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to HTML with disabled interlinking renders no link', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'interlinkingLinkInline' as const,
|
||||
props: {
|
||||
docId: '00000000-0000-0000-0000-000000000123',
|
||||
title: 'Hidden',
|
||||
disabled: true,
|
||||
trigger: '/' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'text/html')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).not.toContain('href=');
|
||||
expect(response.text).not.toContain('data-doc-id');
|
||||
expect(response.text).not.toContain('Hidden');
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to BlockNote JSON preserves pageBreak block', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [{ type: 'text' as const, text: 'before', styles: {} }],
|
||||
},
|
||||
{ type: 'pageBreak' as const },
|
||||
{
|
||||
type: 'paragraph' as const,
|
||||
content: [{ type: 'text' as const, text: 'after', styles: {} }],
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'application/json')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const types = (response.body as { type: string }[]).map((b) => b.type);
|
||||
expect(types).toContain('pageBreak');
|
||||
});
|
||||
|
||||
test('POST /api/convert Yjs to BlockNote JSON preserves uploadLoader block', async () => {
|
||||
const app = initApp();
|
||||
const editor = ServerBlockNoteEditor.create({
|
||||
schema: docsBlockNoteSchema,
|
||||
});
|
||||
const blocks = [
|
||||
{
|
||||
type: 'uploadLoader' as const,
|
||||
props: {
|
||||
information: 'uploading',
|
||||
type: 'loading' as const,
|
||||
blockUploadName: 'doc.pdf',
|
||||
},
|
||||
},
|
||||
];
|
||||
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
|
||||
const yjsUpdate = Y.encodeStateAsUpdate(yDocument);
|
||||
const response = await request(app)
|
||||
.post('/api/convert')
|
||||
.set('origin', origin)
|
||||
.set('authorization', `Bearer ${apiKey}`)
|
||||
.set('content-type', 'application/vnd.yjs.doc')
|
||||
.set('accept', 'application/json')
|
||||
.send(Buffer.from(yjsUpdate));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const uploadLoader = (
|
||||
response.body as { type: string; props: Record<string, unknown> }[]
|
||||
).find((b) => b.type === 'uploadLoader');
|
||||
expect(uploadLoader).toBeDefined();
|
||||
expect(uploadLoader?.props).toMatchObject({
|
||||
information: 'uploading',
|
||||
type: 'loading',
|
||||
blockUploadName: 'doc.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
test('POST /api/convert with invalid Yjs content returns 400', async () => {
|
||||
const destroySpy = vi.spyOn(Y.Doc.prototype, 'destroy');
|
||||
const app = initApp();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.51.4",
|
||||
"@blocknote/server-util": "0.51.4",
|
||||
"@hocuspocus/server": "3.4.4",
|
||||
"@sentry/node": "10.53.1",
|
||||
@@ -30,7 +31,6 @@
|
||||
"yjs": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocknote/core": "0.51.4",
|
||||
"@hocuspocus/provider": "3.4.4",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.6",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createBlockSpec, defaultProps } from '@blocknote/core';
|
||||
|
||||
// Must stay in sync with the frontend CalloutBlock propSchema
|
||||
// (custom-blocks/CalloutBlock.tsx).
|
||||
const calloutPropSchema = {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: { default: 'default' as const },
|
||||
emoji: { default: '💡' as const },
|
||||
} as const;
|
||||
|
||||
const calloutConfig = {
|
||||
type: 'callout' as const,
|
||||
propSchema: calloutPropSchema,
|
||||
content: 'inline' as const,
|
||||
};
|
||||
|
||||
export const CalloutBlock = createBlockSpec(calloutConfig, {
|
||||
render: (block) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.setAttribute('data-content-type', 'callout');
|
||||
dom.setAttribute('data-emoji', block.props.emoji);
|
||||
if (block.props.backgroundColor !== 'default') {
|
||||
dom.setAttribute('data-background-color', block.props.backgroundColor);
|
||||
}
|
||||
const contentDOM = document.createElement('p');
|
||||
dom.appendChild(contentDOM);
|
||||
return { dom, contentDOM };
|
||||
},
|
||||
toExternalHTML: (block) => {
|
||||
const dom = document.createElement('aside');
|
||||
dom.setAttribute('role', 'note');
|
||||
dom.setAttribute('data-emoji', block.props.emoji);
|
||||
if (block.props.backgroundColor !== 'default') {
|
||||
dom.setAttribute('data-background-color', block.props.backgroundColor);
|
||||
}
|
||||
// The emoji lives *inside* contentDOM so rehype-remark (markdown export)
|
||||
// sees a single text-bearing child and doesn't drop the body text.
|
||||
// BlockNote appends inline content to contentDOM, so the emoji stays first.
|
||||
// The data-emoji marker lets downstream parsers strip the duplicated emoji
|
||||
// when reading the callout back (the canonical emoji is on the <aside>).
|
||||
const contentDOM = document.createElement('p');
|
||||
const emoji = document.createElement('span');
|
||||
emoji.setAttribute('aria-hidden', 'true');
|
||||
emoji.setAttribute('data-emoji', block.props.emoji);
|
||||
emoji.textContent = `${block.props.emoji} `;
|
||||
contentDOM.appendChild(emoji);
|
||||
dom.appendChild(contentDOM);
|
||||
return { dom, contentDOM };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createInlineContentSpec } from '@blocknote/core';
|
||||
|
||||
const interlinkingPropSchema = {
|
||||
docId: { default: '' as string },
|
||||
disabled: {
|
||||
default: false as boolean,
|
||||
values: [true, false] as const,
|
||||
},
|
||||
trigger: {
|
||||
default: '/' as const,
|
||||
values: ['/', '@'] as const,
|
||||
},
|
||||
title: { default: '' as string },
|
||||
} as const;
|
||||
|
||||
const interlinkingConfig = {
|
||||
type: 'interlinkingLinkInline' as const,
|
||||
propSchema: interlinkingPropSchema,
|
||||
content: 'none' as const,
|
||||
};
|
||||
|
||||
// Matches the frontend route (LinkSelected.tsx) so exported HTML/markdown
|
||||
// links resolve identically client-side.
|
||||
const interlinkingHref = (docId: string) =>
|
||||
`/docs/${encodeURIComponent(docId)}/`;
|
||||
|
||||
export const InterlinkingLinkInline = createInlineContentSpec(
|
||||
interlinkingConfig,
|
||||
{
|
||||
render: (inlineContent) => {
|
||||
const { disabled, docId, title } = inlineContent.props;
|
||||
if (disabled || !docId) {
|
||||
return { dom: document.createElement('span') };
|
||||
}
|
||||
|
||||
const dom = document.createElement('a');
|
||||
dom.setAttribute('data-inline-content-type', 'interlinkingLinkInline');
|
||||
dom.setAttribute('href', interlinkingHref(docId));
|
||||
dom.setAttribute('data-doc-id', docId);
|
||||
dom.textContent = title || docId;
|
||||
return { dom };
|
||||
},
|
||||
toExternalHTML: (inlineContent) => {
|
||||
const { disabled, docId, title } = inlineContent.props;
|
||||
// Matches the frontend (InterlinkingLinkInlineContent.tsx): a disabled
|
||||
// or unresolved link must not render any visible content.
|
||||
if (disabled || !docId) {
|
||||
return { dom: document.createElement('span') };
|
||||
}
|
||||
|
||||
const dom = document.createElement('a');
|
||||
dom.setAttribute('href', interlinkingHref(docId));
|
||||
dom.setAttribute('data-doc-id', docId);
|
||||
if (title) {
|
||||
dom.setAttribute('title', title);
|
||||
}
|
||||
dom.textContent = title || docId;
|
||||
return { dom };
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createBlockSpec } from '@blocknote/core';
|
||||
|
||||
const pdfPropSchema = {
|
||||
backgroundColor: { default: 'default' as const },
|
||||
caption: { default: '' as string },
|
||||
name: { default: '' as string },
|
||||
previewWidth: { default: undefined, type: 'number' as const },
|
||||
showPreview: { default: true as boolean },
|
||||
textAlignment: { default: 'left' as const },
|
||||
url: { default: '' as string },
|
||||
} as const;
|
||||
|
||||
const pdfConfig = {
|
||||
type: 'pdf' as const,
|
||||
propSchema: pdfPropSchema,
|
||||
content: 'none' as const,
|
||||
};
|
||||
|
||||
// Reject schemes like `javascript:` that would execute on click/load. Allow
|
||||
// http(s) (the upload backend) and protocol-relative/relative paths.
|
||||
const isSafePdfUrl = (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url, 'http://_');
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildPdfDom = (block: {
|
||||
props: {
|
||||
url: string;
|
||||
name: string;
|
||||
caption: string;
|
||||
previewWidth: number | undefined;
|
||||
showPreview: boolean;
|
||||
};
|
||||
}) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-content-type', 'pdf');
|
||||
|
||||
const safeUrl = block.props.url && isSafePdfUrl(block.props.url);
|
||||
|
||||
if (safeUrl && block.props.showPreview !== false) {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('src', block.props.url);
|
||||
iframe.setAttribute('title', block.props.name || 'PDF preview');
|
||||
if (block.props.previewWidth) {
|
||||
iframe.setAttribute('width', String(block.props.previewWidth));
|
||||
}
|
||||
wrapper.appendChild(iframe);
|
||||
} else if (safeUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', block.props.url);
|
||||
link.textContent = block.props.name || block.props.url;
|
||||
wrapper.appendChild(link);
|
||||
} else if (block.props.url) {
|
||||
const fallback = document.createElement('span');
|
||||
fallback.textContent = block.props.name || block.props.url;
|
||||
wrapper.appendChild(fallback);
|
||||
}
|
||||
|
||||
if (block.props.caption) {
|
||||
const caption = document.createElement('p');
|
||||
caption.className = 'bn-file-caption';
|
||||
caption.textContent = block.props.caption;
|
||||
wrapper.appendChild(caption);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
export const PdfBlock = createBlockSpec(pdfConfig, {
|
||||
render: (block) => ({ dom: buildPdfDom(block) }),
|
||||
toExternalHTML: (block) => ({ dom: buildPdfDom(block) }),
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createBlockSpec } from '@blocknote/core';
|
||||
|
||||
const uploadLoaderPropSchema = {
|
||||
information: { default: '' as string },
|
||||
type: {
|
||||
default: 'loading' as const,
|
||||
values: ['loading', 'warning'] as const,
|
||||
},
|
||||
blockUploadName: { default: '' as string },
|
||||
blockUploadShowPreview: { default: true as boolean },
|
||||
blockUploadType: { default: '' as string },
|
||||
blockUploadUrl: { default: '' as string },
|
||||
} as const;
|
||||
|
||||
const uploadLoaderConfig = {
|
||||
type: 'uploadLoader' as const,
|
||||
propSchema: uploadLoaderPropSchema,
|
||||
content: 'none' as const,
|
||||
};
|
||||
|
||||
// Transient block representing an in-progress upload. We render it as an empty
|
||||
// element in HTML export so it disappears from finished documents but the
|
||||
// prosemirror node round-trips correctly.
|
||||
export const UploadLoaderBlock = createBlockSpec(uploadLoaderConfig, {
|
||||
render: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.setAttribute('data-content-type', 'uploadLoader');
|
||||
return { dom };
|
||||
},
|
||||
toExternalHTML: () => {
|
||||
const dom = document.createElement('div');
|
||||
dom.setAttribute('data-content-type', 'uploadLoader');
|
||||
return { dom };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
BlockNoteSchema,
|
||||
defaultBlockSpecs,
|
||||
defaultInlineContentSpecs,
|
||||
withPageBreak,
|
||||
} from '@blocknote/core';
|
||||
|
||||
import { CalloutBlock } from './Callout';
|
||||
import { InterlinkingLinkInline } from './InterlinkingLinkInline';
|
||||
import { PdfBlock } from './Pdf';
|
||||
import { UploadLoaderBlock } from './UploadLoader';
|
||||
|
||||
// Must stay in sync with the frontend schema (BlockNoteEditor.tsx) so Yjs
|
||||
// documents authored client-side round-trip without dropping nodes.
|
||||
export const docsBlockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock(),
|
||||
pdf: PdfBlock(),
|
||||
uploadLoader: UploadLoaderBlock(),
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
interlinkingLinkInline: InterlinkingLinkInline,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type DocsBlockSchema = typeof docsBlockNoteSchema.blockSchema;
|
||||
export type DocsInlineContentSchema =
|
||||
typeof docsBlockNoteSchema.inlineContentSchema;
|
||||
export type DocsStyleSchema = typeof docsBlockNoteSchema.styleSchema;
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
DefaultBlockSchema,
|
||||
DefaultInlineContentSchema,
|
||||
DefaultStyleSchema,
|
||||
PartialBlock,
|
||||
} from '@blocknote/core';
|
||||
import { PartialBlock } from '@blocknote/core';
|
||||
import { ServerBlockNoteEditor } from '@blocknote/server-util';
|
||||
import { Request, Response } from 'express';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema,
|
||||
docsBlockNoteSchema,
|
||||
} from '@/blockSpecs';
|
||||
import { logger } from '@/utils';
|
||||
|
||||
interface ErrorResponse {
|
||||
@@ -16,21 +17,27 @@ interface ErrorResponse {
|
||||
|
||||
type ConversionResponseBody = Uint8Array | string | object | ErrorResponse;
|
||||
|
||||
type DocsPartialBlock = PartialBlock<
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>;
|
||||
|
||||
interface InputReader {
|
||||
supportedContentTypes: string[];
|
||||
read(data: Buffer): Promise<PartialBlock[]>;
|
||||
read(data: Buffer): Promise<DocsPartialBlock[]>;
|
||||
}
|
||||
|
||||
interface OutputWriter {
|
||||
supportedContentTypes: string[];
|
||||
write(blocks: PartialBlock[]): Promise<ConversionResponseBody>;
|
||||
write(blocks: DocsPartialBlock[]): Promise<ConversionResponseBody>;
|
||||
}
|
||||
|
||||
const editor = ServerBlockNoteEditor.create<
|
||||
DefaultBlockSchema,
|
||||
DefaultInlineContentSchema,
|
||||
DefaultStyleSchema
|
||||
>();
|
||||
DocsBlockSchema,
|
||||
DocsInlineContentSchema,
|
||||
DocsStyleSchema
|
||||
>({ schema: docsBlockNoteSchema });
|
||||
|
||||
const ContentTypes = {
|
||||
XMarkdown: 'text/x-markdown',
|
||||
@@ -43,7 +50,7 @@ const ContentTypes = {
|
||||
JSON: 'application/json',
|
||||
} as const;
|
||||
|
||||
const createYDocument = (blocks: PartialBlock[]) =>
|
||||
const createYDocument = (blocks: DocsPartialBlock[]) =>
|
||||
editor.blocksToYDoc(blocks, 'document-store');
|
||||
|
||||
const readers: InputReader[] = [
|
||||
@@ -135,13 +142,7 @@ export const convertHandler = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let blocks:
|
||||
| PartialBlock<
|
||||
DefaultBlockSchema,
|
||||
DefaultInlineContentSchema,
|
||||
DefaultStyleSchema
|
||||
>[]
|
||||
| null;
|
||||
let blocks: DocsPartialBlock[] | null;
|
||||
try {
|
||||
try {
|
||||
blocks = await reader.read(req.body);
|
||||
|
||||
Reference in New Issue
Block a user