mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
1 Commits
config/inc
...
hack2025/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30382c24fc |
@@ -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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './CalloutBlock';
|
||||||
|
export * from './DividerBlock';
|
||||||
Reference in New Issue
Block a user