From 017b09ba352f680f2798690fd8b707d7d2666074 Mon Sep 17 00:00:00 2001 From: brendanlaschke Date: Wed, 20 Mar 2024 08:38:05 +0100 Subject: [PATCH] Blocknote custom slash menu (#4517) blocknote v12, cleaned up blockschema & specs, added custom slash menu --- package.json | 4 +- .../modules/activities/blocks/FileBlock.tsx | 175 ++++++++---------- .../modules/activities/blocks/blockSpecs.ts | 8 - .../src/modules/activities/blocks/schema.ts | 12 +- .../modules/activities/blocks/slashMenu.tsx | 57 +++--- .../components/ActivityBodyEditor.tsx | 27 +-- .../src/modules/ui/display/icon/index.ts | 4 + .../input/editor/components/BlockEditor.tsx | 45 ++++- .../editor/components/CustomSlashMenu.tsx | 42 +++++ .../components/MenuItemSuggestion.tsx | 79 ++++++++ yarn.lock | 38 ++-- 11 files changed, 308 insertions(+), 183 deletions(-) delete mode 100644 packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts create mode 100644 packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx create mode 100644 packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemSuggestion.tsx diff --git a/package.json b/package.json index 8f43c73685..b99715b829 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "@apollo/server": "^4.7.3", "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/credential-providers": "^3.363.0", - "@blocknote/core": "^0.11.2", - "@blocknote/react": "^0.11.2", + "@blocknote/core": "^0.12.1", + "@blocknote/react": "^0.12.1", "@chakra-ui/accordion": "^2.3.0", "@chakra-ui/system": "^2.6.0", "@codesandbox/sandpack-react": "^2.13.5", diff --git a/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx b/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx index de679ea553..1230d592b4 100644 --- a/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx @@ -1,11 +1,4 @@ import { ChangeEvent, useRef } from 'react'; -import { - BlockFromConfig, - BlockNoteEditor, - InlineContentSchema, - PropSchema, - StyleSchema, -} from '@blocknote/core'; import { createReactBlockSpec } from '@blocknote/react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; @@ -19,31 +12,10 @@ import { AttachmentIcon } from '../files/components/AttachmentIcon'; import { AttachmentType } from '../files/types/Attachment'; import { getFileType } from '../files/utils/getFileType'; -import { blockSpecs } from './blockSpecs'; - -export const filePropSchema = { - // File url - url: { - default: '' as string, - }, - name: { - default: '' as string, - }, - fileType: { - default: 'Other' as AttachmentType, - }, -} satisfies PropSchema; - const StyledFileInput = styled.input` display: none; `; -const FileBlockConfig = { - type: 'file' as const, - propSchema: filePropSchema, - content: 'none' as const, -}; - const StyledFileLine = styled.div` align-items: center; display: flex; @@ -66,75 +38,82 @@ const StyledUploadFileContainer = styled.div` gap: ${({ theme }) => theme.spacing(2)}; `; -const FileBlockRenderer = ({ - block, - editor, -}: { - block: BlockFromConfig< - typeof FileBlockConfig, - InlineContentSchema, - StyleSchema - >; - editor: BlockNoteEditor; -}) => { - const inputFileRef = useRef(null); - - const handleUploadAttachment = async (file: File) => { - if (isUndefinedOrNull(file)) { - return ''; - } - const fileUrl = await editor.uploadFile?.(file); - - editor.updateBlock(block.id, { - props: { - ...block.props, - ...{ url: fileUrl, fileType: getFileType(file.name), name: file.name }, +export const FileBlock = createReactBlockSpec( + { + type: 'file', + propSchema: { + url: { + default: '' as string, }, - }); - }; - const handleUploadFileClick = () => { - inputFileRef?.current?.click?.(); - }; - const handleFileChange = (e: ChangeEvent) => { - if (isDefined(e.target.files)) handleUploadAttachment?.(e.target.files[0]); - }; - - if (isNonEmptyString(block.props.url)) { - return ( - - - - - {block.props.name} - - - - ); - } - - return ( - - - - - - - ); -}; - -export const FileBlock = createReactBlockSpec(FileBlockConfig, { - render: (block) => { - return ( - - ); + name: { + default: '' as string, + }, + fileType: { + default: 'Other' as AttachmentType, + }, + }, + content: 'none', }, -}); + { + render: ({ block, editor }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const inputFileRef = useRef(null); + + const handleUploadAttachment = async (file: File) => { + if (isUndefinedOrNull(file)) { + return ''; + } + const fileUrl = await editor.uploadFile?.(file); + + editor.updateBlock(block.id, { + props: { + ...block.props, + ...{ + url: fileUrl, + fileType: getFileType(file.name), + name: file.name, + }, + }, + }); + }; + const handleUploadFileClick = () => { + inputFileRef?.current?.click?.(); + }; + const handleFileChange = (e: ChangeEvent) => { + if (isDefined(e.target.files)) + handleUploadAttachment?.(e.target.files[0]); + }; + + if (isNonEmptyString(block.props.url)) { + return ( + + + + + {block.props.name} + + + + ); + } + + return ( + + + + + + + ); + }, + }, +); diff --git a/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts b/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts deleted file mode 100644 index fd95c52f24..0000000000 --- a/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defaultBlockSpecs } from '@blocknote/core'; - -import { FileBlock } from './FileBlock'; - -export const blockSpecs: any = { - ...defaultBlockSpecs, - file: FileBlock, -}; diff --git a/packages/twenty-front/src/modules/activities/blocks/schema.ts b/packages/twenty-front/src/modules/activities/blocks/schema.ts index 78e83e7d20..d6ea82eac1 100644 --- a/packages/twenty-front/src/modules/activities/blocks/schema.ts +++ b/packages/twenty-front/src/modules/activities/blocks/schema.ts @@ -1,8 +1,10 @@ -import { BlockSchema, defaultBlockSchema } from '@blocknote/core'; +import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'; import { FileBlock } from './FileBlock'; -export const blockSchema: BlockSchema = { - ...defaultBlockSchema, - file: FileBlock.config, -}; +export const blockSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + file: FileBlock, + }, +}); diff --git a/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx b/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx index 437d61f3fd..6689d1aa87 100644 --- a/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx @@ -1,30 +1,45 @@ -import { - BlockNoteEditor, - InlineContentSchema, - StyleSchema, -} from '@blocknote/core'; import { getDefaultReactSlashMenuItems } from '@blocknote/react'; -import { IconFile } from '@/ui/display/icon'; +import { + IconFile, + IconH1, + IconH2, + IconH3, + IconList, + IconListNumbers, + IconPhoto, + IconPilcrow, + IconTable, +} from '@/ui/display/icon'; +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { SuggestionItem } from '@/ui/input/editor/components/CustomSlashMenu'; import { blockSchema } from './schema'; -export const getSlashMenu = () => { - const items = [ - ...getDefaultReactSlashMenuItems(blockSchema), +const Icons: Record = { + 'Heading 1': IconH1, + 'Heading 2': IconH2, + 'Heading 3': IconH3, + 'Numbered List': IconListNumbers, + 'Bullet List': IconList, + Paragraph: IconPilcrow, + Table: IconTable, + Image: IconPhoto, +}; + +export const getSlashMenu = (editor: typeof blockSchema.BlockNoteEditor) => { + const items: SuggestionItem[] = [ + ...getDefaultReactSlashMenuItems(editor).map((x) => ({ + ...x, + Icon: Icons[x.title], + })), { - name: 'File', + title: 'File', aliases: ['file', 'folder'], - group: 'Media', - icon: , - hint: 'Insert a file', - execute: ( - editor: BlockNoteEditor< - typeof blockSchema, - InlineContentSchema, - StyleSchema - >, - ) => { + Icon: IconFile, + onItemClick: () => { + const currentBlock = editor.getTextCursorPosition().block; + editor.insertBlocks( [ { @@ -34,7 +49,7 @@ export const getSlashMenu = () => { }, }, ], - editor.getTextCursorPosition().block, + currentBlock, 'before', ); }, diff --git a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx index 16a862c405..fbc61bb3cc 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx @@ -1,6 +1,5 @@ -import { useCallback, useMemo } from 'react'; -import { BlockNoteEditor } from '@blocknote/core'; -import { useBlockNote } from '@blocknote/react'; +import { ClipboardEvent, useCallback, useMemo } from 'react'; +import { useCreateBlockNote } from '@blocknote/react'; import styled from '@emotion/styled'; import { isArray, isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback, useRecoilState } from 'recoil'; @@ -8,6 +7,7 @@ import { Key } from 'ts-key-enum'; import { useDebouncedCallback } from 'use-debounce'; import { v4 } from 'uuid'; +import { blockSchema } from '@/activities/blocks/schema'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState'; import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState'; @@ -28,8 +28,6 @@ import { FileFolder, useUploadFileMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { blockSpecs } from '../blocks/blockSpecs'; -import { getSlashMenu } from '../blocks/slashMenu'; import { getFileType } from '../files/utils/getFileType'; import '@blocknote/react/style.css'; @@ -121,8 +119,6 @@ export const ActivityBodyEditor = ({ canCreateActivityState(), ); - const slashMenuItems = getSlashMenu(); - const [uploadFile] = useUploadFileMutation(); const handleUploadAttachment = async (file: File): Promise => { @@ -216,8 +212,8 @@ export const ActivityBodyEditor = ({ const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); - const handleEditorChange = (newEditor: BlockNoteEditor) => { - const newStringifiedBody = JSON.stringify(newEditor.topLevelBlocks) ?? ''; + const handleEditorChange = () => { + const newStringifiedBody = JSON.stringify(editor.document) ?? ''; setActivityBody(newStringifiedBody); @@ -238,16 +234,11 @@ export const ActivityBodyEditor = ({ } }, [activity, activityBody]); - const editor: BlockNoteEditor | null = useBlockNote({ + const editor = useCreateBlockNote({ initialContent: initialBody, domAttributes: { editor: { class: 'editor' } }, - onEditorContentChange: handleEditorChange, - slashMenuItems, - blockSpecs: blockSpecs, + schema: blockSchema, uploadFile: handleUploadAttachment, - onEditorReady: (editor: BlockNoteEditor) => { - editor.domElement.addEventListener('paste', handleImagePaste); - }, }); const handleImagePaste = async (event: ClipboardEvent) => { @@ -361,7 +352,7 @@ export const ActivityBodyEditor = ({ const newBlockId = v4(); const newBlock = { id: newBlockId, - type: 'paragraph', + type: 'paragraph' as const, content: keyboardEvent.key, }; editor.insertBlocks([newBlock], blockIdentifier, 'after'); @@ -387,6 +378,8 @@ export const ActivityBodyEditor = ({ diff --git a/packages/twenty-front/src/modules/ui/display/icon/index.ts b/packages/twenty-front/src/modules/ui/display/icon/index.ts index ae1b058dc9..74f81ccb9b 100644 --- a/packages/twenty-front/src/modules/ui/display/icon/index.ts +++ b/packages/twenty-front/src/modules/ui/display/icon/index.ts @@ -67,6 +67,9 @@ export { IconFilterOff, IconForbid, IconGripVertical, + IconH1, + IconH2, + IconH3, IconHeadphones, IconHeart, IconHeartOff, @@ -97,6 +100,7 @@ export { IconPencil, IconPhone, IconPhoto, + IconPilcrow, IconPlug, IconPlus, IconPresentation, diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx index 0ca49bca52..fc51383597 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx +++ b/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx @@ -1,12 +1,22 @@ -import { BlockNoteEditor } from '@blocknote/core'; -import { BlockNoteView } from '@blocknote/react'; +import { ClipboardEvent } from 'react'; +import { filterSuggestionItems } from '@blocknote/core'; +import { BlockNoteView, SuggestionMenuController } from '@blocknote/react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { blockSchema } from '@/activities/blocks/schema'; +import { getSlashMenu } from '@/activities/blocks/slashMenu'; +import { + CustomSlashMenu, + SuggestionItem, +} from '@/ui/input/editor/components/CustomSlashMenu'; + interface BlockEditorProps { - editor: BlockNoteEditor; + editor: typeof blockSchema.BlockNoteEditor; onFocus?: () => void; onBlur?: () => void; + onPaste?: (event: ClipboardEvent) => void; + onChange?: () => void; } const StyledEditor = styled.div` @@ -22,7 +32,13 @@ const StyledEditor = styled.div` } `; -export const BlockEditor = ({ editor, onFocus, onBlur }: BlockEditorProps) => { +export const BlockEditor = ({ + editor, + onFocus, + onBlur, + onChange, + onPaste, +}: BlockEditorProps) => { const theme = useTheme(); const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark'; @@ -34,14 +50,33 @@ export const BlockEditor = ({ editor, onFocus, onBlur }: BlockEditorProps) => { onBlur?.(); }; + const handleChange = () => { + onChange?.(); + }; + + const handlePaste = (event: ClipboardEvent) => { + onPaste?.(event); + }; + return ( + slashMenu={false} + > + + filterSuggestionItems(getSlashMenu(editor), query) + } + suggestionMenuComponent={CustomSlashMenu} + /> + ); }; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx new file mode 100644 index 0000000000..509fd9a740 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx @@ -0,0 +1,42 @@ +import { SuggestionMenuProps } from '@blocknote/react'; +import styled from '@emotion/styled'; + +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { MenuItemSuggestion } from '@/ui/navigation/menu-item/components/MenuItemSuggestion'; + +export type SuggestionItem = { + title: string; + onItemClick: () => void; + aliases?: string[]; + Icon?: IconComponent; +}; + +type CustomSlashMenuProps = SuggestionMenuProps; + +const StyledSlashMenu = styled.div` + * { + box-sizing: content-box; + } +`; + +export const CustomSlashMenu = (props: CustomSlashMenuProps) => { + return ( + + + + {props.items.map((item, index) => ( + item.onItemClick()} + text={item.title} + LeftIcon={item.Icon} + selected={props.selectedIndex === index} + /> + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemSuggestion.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemSuggestion.tsx new file mode 100644 index 0000000000..52d3a12ee8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemSuggestion.tsx @@ -0,0 +1,79 @@ +import { MouseEvent } from 'react'; +import styled from '@emotion/styled'; + +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { HOVER_BACKGROUND } from '@/ui/theme/constants/HoverBackground'; + +import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent'; +import { StyledMenuItemLeftContent } from '../internals/components/StyledMenuItemBase'; + +export type MenuItemSuggestionProps = { + LeftIcon?: IconComponent | null; + text: string; + selected?: boolean; + className?: string; + onClick?: (event: MouseEvent) => void; +}; + +const StyledSuggestionMenuItem = styled.li<{ + selected?: boolean; +}>` + --horizontal-padding: ${({ theme }) => theme.spacing(1)}; + --vertical-padding: ${({ theme }) => theme.spacing(2)}; + + align-items: center; + + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: pointer; + + display: flex; + + flex-direction: row; + + font-size: ${({ theme }) => theme.font.size.sm}; + + gap: ${({ theme }) => theme.spacing(2)}; + + height: calc(32px - 2 * var(--vertical-padding)); + justify-content: space-between; + + padding: var(--vertical-padding) var(--horizontal-padding); + + background: ${({ selected, theme }) => + selected ? theme.background.transparent.medium : ''}; + + ${HOVER_BACKGROUND}; + + position: relative; + user-select: none; + + width: calc(100% - 2 * var(--horizontal-padding)); +`; + +export const MenuItemSuggestion = ({ + LeftIcon, + text, + className, + selected, + onClick, +}: MenuItemSuggestionProps) => { + const handleMenuItemClick = (event: MouseEvent) => { + if (!onClick) return; + event.preventDefault(); + event.stopPropagation(); + + onClick?.(event); + }; + + return ( + + + + + + ); +}; diff --git a/yarn.lock b/yarn.lock index ce0cde229f..f8806b2b7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,9 +3555,9 @@ __metadata: languageName: node linkType: hard -"@blocknote/core@npm:^0.11.2": - version: 0.11.2 - resolution: "@blocknote/core@npm:0.11.2" +"@blocknote/core@npm:^0.12.1": + version: 0.12.1 + resolution: "@blocknote/core@npm:0.12.1" dependencies: "@tiptap/core": "npm:^2.0.3" "@tiptap/extension-bold": "npm:^2.0.3" @@ -3598,23 +3598,21 @@ __metadata: y-prosemirror: "npm:1.2.1" y-protocols: "npm:^1.0.5" yjs: "npm:^13.6.1" - checksum: a9a5affe263481bf46b556ded4b5dd583f24e0b090d536550eb2484f7d091e76258d8a865171459fbf3be114abee638e406d419d0d32f07aa5a82c638d00fcb3 + checksum: 3b00572a2d98964722b7bcd75f8399856c3fcdcbc88e7217e883d7f6eba13fa36aeea8dde439335c9ea475a25f179b771fa397e9b78219c608c74663db1557f2 languageName: node linkType: hard -"@blocknote/react@npm:^0.11.2": - version: 0.11.2 - resolution: "@blocknote/react@npm:0.11.2" +"@blocknote/react@npm:^0.12.1": + version: 0.12.2 + resolution: "@blocknote/react@npm:0.12.2" dependencies: - "@blocknote/core": "npm:^0.11.2" + "@blocknote/core": "npm:^0.12.1" "@floating-ui/react": "npm:^0.26.4" "@mantine/core": "npm:^7.5.0" "@mantine/hooks": "npm:^7.5.0" "@mantine/utils": "npm:^6.0.21" "@tiptap/core": "npm:^2.0.3" "@tiptap/react": "npm:^2.0.3" - lodash.foreach: "npm:^4.5.0" - lodash.groupby: "npm:^4.6.0" lodash.merge: "npm:^4.6.2" react: "npm:^18" react-dom: "npm:^18.2.0" @@ -3623,7 +3621,7 @@ __metadata: peerDependencies: react: ^18 react-dom: ^18 - checksum: a3dcfd40ea3e3b0e4c238b161be908e726b1d404337efe9903e92b7cf518d3165a7df8d78bce606f86354198751fd009d96f1cc8f23de51734e9faa663225981 + checksum: 2dcae5268e104398bb2be13f650b6d141eecc74542dcc38b78b3cad7cc499e2e2fd5ecd225ce969fbfadd346cc74d2354316fbb2a61ce5f38c2d339849dd4b0e languageName: node linkType: hard @@ -33362,13 +33360,6 @@ __metadata: languageName: node linkType: hard -"lodash.foreach@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.foreach@npm:4.5.0" - checksum: bd9cc83e87e805b21058ce6cf718dd22db137c7ca08eddbd608549db59989911c571b7195707f615cb37f27bb4f9a9fa9980778940d768c24095f5a04b244c84 - languageName: node - linkType: hard - "lodash.get@npm:^4.4.2": version: 4.4.2 resolution: "lodash.get@npm:4.4.2" @@ -33376,13 +33367,6 @@ __metadata: languageName: node linkType: hard -"lodash.groupby@npm:^4.6.0": - version: 4.6.0 - resolution: "lodash.groupby@npm:4.6.0" - checksum: 3d136cad438ad6c3a078984ef60e057a3498b1312aa3621b00246ecb99e8f2c4d447e2815460db7a0b661a4fe4e2eeee96c84cb661a824bad04b6cf1f7bc6e9b - languageName: node - linkType: hard - "lodash.includes@npm:^4.3.0": version: 4.3.0 resolution: "lodash.includes@npm:4.3.0" @@ -45826,8 +45810,8 @@ __metadata: "@aws-sdk/credential-providers": "npm:^3.363.0" "@babel/core": "npm:^7.14.5" "@babel/preset-react": "npm:^7.14.5" - "@blocknote/core": "npm:^0.11.2" - "@blocknote/react": "npm:^0.11.2" + "@blocknote/core": "npm:^0.12.1" + "@blocknote/react": "npm:^0.12.1" "@chakra-ui/accordion": "npm:^2.3.0" "@chakra-ui/system": "npm:^2.6.0" "@codesandbox/sandpack-react": "npm:^2.13.5"