Compare commits

...

2 Commits

Author SHA1 Message Date
Anthony LC
30382c24fc add custom block to convertBlockHandlers 2025-06-03 17:46:40 +02:00
renovate[bot]
d952815932 ⬆️(dependencies) update python dependencies 2025-06-02 05:09:03 +00:00
5 changed files with 300 additions and 9 deletions

View File

@@ -26,9 +26,9 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.23",
"boto3==1.38.27",
"Brotli==1.1.0",
"celery[redis]==5.5.2",
"celery[redis]==5.5.3",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -46,12 +46,12 @@ dependencies = [
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"jsonschema==4.24.0",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.82.0",
"openai==1.82.1",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.20",
"PyJWT==2.10.1",
@@ -72,10 +72,10 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.5.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"ipdb==0.13.13",
"ipython==9.2.0",
"ipython==9.3.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
@@ -83,10 +83,10 @@ dev = [
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"pytest-xdist==3.7.0",
"responses==0.25.7",
"ruff==0.11.11",
"types-requests==2.32.0.20250515",
"ruff==0.11.12",
"types-requests==2.32.0.20250602",
]
[tool.setuptools]

View File

@@ -0,0 +1,72 @@
import {
BlockNoteSchema,
PartialBlock,
defaultBlockSpecs,
} from '@blocknote/core';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { Request, Response } from 'express';
import * as Y from 'yjs';
import { logger, toBase64 } from '@/utils';
import { CalloutBlock, DividerBlock } from './custom-blocks';
const blockNoteSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
},
});
type DocsBlockSchema = typeof blockNoteSchema.blockSchema;
type DocsInlineContentSchema = typeof blockNoteSchema.inlineContentSchema;
type DocsStyleSchema = typeof blockNoteSchema.styleSchema;
interface ConversionRequest {
blocks: PartialBlock<DocsBlockSchema>[];
}
interface ConversionResponse {
content: string;
}
interface ErrorResponse {
error: string;
}
export const convertBlocksHandler = (
req: Request<
object,
ConversionResponse | ErrorResponse,
ConversionRequest,
object
>,
res: Response<ConversionResponse | ErrorResponse>,
) => {
const blocks = req.body?.blocks;
if (!blocks) {
res.status(400).json({ error: 'Invalid request: missing content' });
return;
}
try {
// Create a server editor with custom block schema
const editor = ServerBlockNoteEditor.create<
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>({
schema: blockNoteSchema,
});
// Create a Yjs Document from blocks, and encode it as a base64 string
const yDocument = editor.blocksToYDoc(blocks, 'document-store');
const content = toBase64(Y.encodeStateAsUpdate(yDocument));
res.status(200).json({ content });
} catch (e) {
logger('conversion failed:', e);
res.status(500).json({ error: String(e) });
}
};

View File

@@ -0,0 +1,166 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const toggleEmojiPicker = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
},
);
export const getCalloutReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'callout',
});
},
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
group,
icon: <Icon iconName="lightbulb" $size="18px" />,
subtext: t('Add a callout block'),
},
];
export const getCalloutFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});

View File

@@ -0,0 +1,51 @@
import { insertOrUpdateBlock } from '@blocknote/core';
import { createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '../../types';
export const DividerBlock = createReactBlockSpec(
{
type: 'divider',
propSchema: {},
content: 'none',
},
{
render: () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { colorsTokens } = useCunninghamTheme();
return (
<Box
as="hr"
$width="100%"
$background={colorsTokens['greyscale-300']}
$margin="1rem 0"
$css={`border: 1px solid ${colorsTokens['greyscale-300']};`}
/>
);
},
},
);
export const getDividerReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Divider'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'divider',
});
},
aliases: ['divider', 'hr', 'horizontal rule', 'line', 'separator'],
group,
icon: <Icon iconName="remove" $size="18px" />,
subtext: t('Add a horizontal line'),
},
];

View File

@@ -0,0 +1,2 @@
export * from './CalloutBlock';
export * from './DividerBlock';