diff --git a/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts new file mode 100644 index 000000000..cdfeed5f2 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts @@ -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[]; +} + +interface ConversionResponse { + content: string; +} + +interface ErrorResponse { + error: string; +} + +export const convertBlocksHandler = ( + req: Request< + object, + ConversionResponse | ErrorResponse, + ConversionRequest, + object + >, + res: Response, +) => { + 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) }); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/custom-blocks/CalloutBlock.tsx b/src/frontend/servers/y-provider/src/handlers/custom-blocks/CalloutBlock.tsx new file mode 100644 index 000000000..bbb0850c1 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/custom-blocks/CalloutBlock.tsx @@ -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 ( + + + {block.props.emoji} + + + {openEmojiPicker && ( + + )} + + + ); + }, + }, +); + +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: , + subtext: t('Add a callout block'), + }, +]; + +export const getCalloutFormattingToolbarItems = ( + t: TFunction<'translation', undefined>, +): BlockTypeSelectItem => ({ + name: t('Callout'), + type: 'callout', + icon: () => , + isSelected: (block) => block.type === 'callout', +}); diff --git a/src/frontend/servers/y-provider/src/handlers/custom-blocks/DividerBlock.tsx b/src/frontend/servers/y-provider/src/handlers/custom-blocks/DividerBlock.tsx new file mode 100644 index 000000000..9d402a823 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/custom-blocks/DividerBlock.tsx @@ -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 ( + + ); + }, + }, +); + +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: , + subtext: t('Add a horizontal line'), + }, +]; diff --git a/src/frontend/servers/y-provider/src/handlers/custom-blocks/index.ts b/src/frontend/servers/y-provider/src/handlers/custom-blocks/index.ts new file mode 100644 index 000000000..34a8c459c --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/custom-blocks/index.ts @@ -0,0 +1,2 @@ +export * from './CalloutBlock'; +export * from './DividerBlock';