Upgrade blocknote dependencies from 0.31.1 to 0.47.0. (#18207)

This PR pgrades all BlockNote packages (@blocknote/core,
@blocknote/react, @blocknote/mantine, @blocknote/server-util,
@blocknote/xl-docx-exporter, @blocknote/xl-pdf-exporter) to 0.47.0 and
adapts the codebase to the new API.

### Changes
- Dependency upgrades: Bumped all BlockNote packages to 0.47.0, added
required Mantine v8 peer dependencies, removed unnecessary prosemirror
resolutions
- Formatting toolbar: Replaced the manual reimplementation of
FormattingToolbarController (which handled visibility, positioning,
portal rendering, text-alignment-based placement, and a
dangerouslySetInnerHTML transition trick) with BlockNote's built-in
FormattingToolbarController. The toolbar buttons themselves are
unchanged.
- Side menu: Replaced manual drag handle menu positioning and rendering
(DashboardBlockDragHandleMenu, DashboardBlockColorPicker, and their
floating configs) with BlockNote's built-in SideMenuController,
DragHandleButton, and DragHandleMenu components. Deleted 4 files that
became dead code.
- Extension API migration: Replaced deprecated editor.suggestionMenus
and editor.formattingToolbar APIs with the new extension system
(SuggestionMenu, useExtensionState, editor.getExtension())
- Slash menu fixes: Filtered out BlockNote's new default "File" item
(added in 0.47) to avoid duplicates with our custom one; added icon
mappings for new block types (Toggle List, Divider, Toggle Headings,
Headings 4-6)
- Server-side: Switched @blocknote/server-util to dynamic import() to
handle ESM-only transitive dependencies in CJS context
This commit is contained in:
Abdullah.
2026-02-27 13:16:49 +05:00
committed by GitHub
parent def5ea5764
commit 4ed09a3feb
26 changed files with 851 additions and 1220 deletions
-2
View File
@@ -201,8 +201,6 @@
"type-fest": "4.10.1",
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
},
+7 -5
View File
@@ -31,10 +31,10 @@
"dependencies": {
"@ai-sdk/react": "3.0.99",
"@apollo/client": "^3.7.17",
"@blocknote/mantine": "^0.31.1",
"@blocknote/react": "^0.31.1",
"@blocknote/xl-docx-exporter": "^0.31.1",
"@blocknote/xl-pdf-exporter": "^0.31.1",
"@blocknote/mantine": "0.47.0",
"@blocknote/react": "0.47.0",
"@blocknote/xl-docx-exporter": "0.47.0",
"@blocknote/xl-pdf-exporter": "0.47.0",
"@calcom/embed-react": "^1.5.3",
"@cyntler/react-doc-viewer": "^1.17.0",
"@dagrejs/dagre": "^1.1.2",
@@ -46,6 +46,9 @@
"@lingui/core": "^5.1.2",
"@lingui/detect-locale": "^5.2.0",
"@lingui/react": "^5.1.2",
"@mantine/core": "^8.3.11",
"@mantine/hooks": "^8.3.11",
"@mantine/utils": "^6.0.22",
"@monaco-editor/react": "^4.7.0",
"@nivo/core": "^0.99.0",
"@nivo/line": "^0.99.0",
@@ -78,7 +81,6 @@
"buffer": "^6.0.3",
"cron-parser": "5.1.1",
"date-fns": "^2.30.0",
"docx": "^9.1.0",
"file-saver": "^2.0.5",
"graphiql": "^3.1.1",
"graphql": "16.8.1",
@@ -1,26 +1,16 @@
import { type BlockNoteEditor } from '@blocknote/core';
import {
docxDefaultSchemaMappings,
DOCXExporter,
} from '@blocknote/xl-docx-exporter';
import { Buffer } from 'buffer';
import { Packer } from 'docx';
import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToDocx = async (
editor: BlockNoteEditor,
filename: string,
) => {
// Polyfill needed for exportBlockNoteEditorToDocX
if (typeof window !== 'undefined') {
window.Buffer = Buffer;
}
const exporter = new DOCXExporter(editor.schema, docxDefaultSchemaMappings);
const docxDocument = await exporter.toDocxJsDocument(editor.document);
const blob = await Packer.toBlob(docxDocument);
const blob = await exporter.toBlob(editor.document);
saveAs(blob, `${filename}.docx`);
};
@@ -42,10 +42,10 @@ export const FileBlock = createReactBlockSpec(
type: 'file',
propSchema: {
url: {
default: '' as string,
default: '',
},
name: {
default: '' as string,
default: '',
},
fileCategory: {
default: 'OTHER' as AttachmentFileCategory,
@@ -10,7 +10,7 @@ import { MentionInlineContent } from '@/blocknote-editor/blocks/MentionInlineCon
export const BLOCK_SCHEMA = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
file: FileBlock,
file: FileBlock(),
},
inlineContentSpecs: {
...defaultInlineContentSpecs,
@@ -1,4 +1,4 @@
import { filterSuggestionItems } from '@blocknote/core';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { BlockNoteView } from '@blocknote/mantine';
import { SuggestionMenuController } from '@blocknote/react';
import { useTheme } from '@emotion/react';
@@ -1,18 +1,16 @@
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { SuggestionMenu } from '@blocknote/core/extensions';
import { useExtensionState } from '@blocknote/react';
import { useStore } from 'jotai';
import { useCallback, useState } from 'react';
import { isSlashMenuOpenComponentState } from '@/blocknote-editor/states/isSlashMenuOpenComponentState';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export type BlockEditorDropdownFocusEffectProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
};
export const BlockEditorDropdownFocusEffect = () => {
const [prevShowing, setPrevShowing] = useState<boolean | null>(null);
export const BlockEditorDropdownFocusEffect = ({
editor,
}: BlockEditorDropdownFocusEffectProps) => {
const isSlashMenuOpenState = useAtomComponentStateCallbackState(
isSlashMenuOpenComponentState,
);
@@ -25,30 +23,21 @@ export const BlockEditorDropdownFocusEffect = ({
const store = useStore();
const updateCallBack = useCallback(
(event: any) => {
const eventWantsToOpen = event.show === true;
const suggestionState = useExtensionState(SuggestionMenu);
const isSlashMenuShowing =
suggestionState?.show === true && suggestionState?.triggerCharacter === '/';
const syncSlashMenuState = useCallback(
(showing: boolean) => {
const isAlreadyOpen = store.get(isSlashMenuOpenState);
const shouldOpen = eventWantsToOpen && !isAlreadyOpen;
if (shouldOpen) {
if (showing && isAlreadyOpen !== true) {
setActiveDropdownFocusIdAndMemorizePrevious('custom-slash-menu');
store.set(isSlashMenuOpenState, true);
return;
}
const eventWantsToClose = event.show === false;
const isAlreadyClosed = !isAlreadyOpen;
const shouldClose = eventWantsToClose && !isAlreadyClosed;
if (shouldClose) {
} else if (!showing && isAlreadyOpen === true) {
goBackToPreviousDropdownFocusId();
store.set(isSlashMenuOpenState, false);
return;
}
},
[
@@ -59,7 +48,10 @@ export const BlockEditorDropdownFocusEffect = ({
],
);
editor.suggestionMenus.on('update /', updateCallBack);
if (prevShowing !== isSlashMenuShowing) {
setPrevShowing(isSlashMenuShowing);
syncSlashMenuState(isSlashMenuShowing);
}
return <></>;
};
@@ -1,42 +1,36 @@
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { useComponentsContext } from '@blocknote/react';
import { SuggestionMenu } from '@blocknote/core/extensions';
import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react';
type CustomAddBlockItemProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
children: React.ReactNode; // Adding the children prop
children: React.ReactNode;
};
type ContentItem = {
type: string;
text: string;
styles: any;
};
export const CustomAddBlockItem = ({
editor,
children,
}: CustomAddBlockItemProps) => {
const Components = useComponentsContext();
const blockNoteEditor = useBlockNoteEditor();
if (!Components) {
return null;
}
const handleClick = () => {
const blockIdentifier = editor.getTextCursorPosition().block;
const currentBlockContent = blockIdentifier?.content as
| Array<ContentItem>
| undefined;
const currentBlock = blockNoteEditor.getTextCursorPosition().block;
const blockContent = currentBlock?.content;
const [firstElement] = currentBlockContent || [];
const isEmpty = Array.isArray(blockContent) && blockContent.length === 0;
if (firstElement === undefined) {
editor.openSuggestionMenu('/');
} else {
editor.openSuggestionMenu('/');
editor.sideMenu.unfreezeMenu();
if (isEmpty) {
editor.setTextCursorPosition(currentBlock);
}
blockNoteEditor.getExtension(SuggestionMenu)?.openSuggestionMenu('/');
};
return (
<Components.Generic.Menu.Item onClick={handleClick}>
{children}
@@ -25,15 +25,11 @@ export const CustomSideMenu = ({ editor }: CustomSideMenuProps) => {
const { t } = useLingui();
return (
<SideMenuController
sideMenu={(props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<SideMenu {...props}>
sideMenu={() => (
<SideMenu>
<DragHandleButton
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
dragHandleMenu={(props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<DragHandleMenu {...props}>
dragHandleMenu={() => (
<DragHandleMenu>
<CustomAddBlockItem editor={editor}>
<CustomSideMenuOptions
LeftIcon={IconPlus}
@@ -41,17 +37,14 @@ export const CustomSideMenu = ({ editor }: CustomSideMenuProps) => {
Variant="normal"
/>
</CustomAddBlockItem>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<BlockColorsItem {...props}>
<BlockColorsItem>
<CustomSideMenuOptions
LeftIcon={IconColorSwatch}
text={t`Change Color`}
Variant="normal"
/>
</BlockColorsItem>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<RemoveBlockItem {...props}>
{' '}
<RemoveBlockItem>
<CustomSideMenuOptions
LeftIcon={IconTrash}
text={t`Delete`}
@@ -33,7 +33,7 @@ export const CustomSlashMenu = ({
const currentBlock = editor?.getTextCursorPosition()?.block;
const blockType = currentBlock?.type;
const headingLevel =
blockType === 'heading' ? (currentBlock?.props?.level as number) : null;
blockType === 'heading' ? Number(currentBlock?.props?.level) : null;
const getOffsetValue = (placement: string) => {
if (!placement.startsWith('top')) return 0;
@@ -1,6 +1,6 @@
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
import { type SuggestionItem } from '@/blocknote-editor/components/CustomSlashMenu';
import { type SuggestionItem } from '@/blocknote-editor/types/types';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import {
@@ -11,10 +11,15 @@ import {
IconH1,
IconH2,
IconH3,
IconH4,
IconH5,
IconH6,
IconHeadphones,
IconList,
IconListCheck,
IconListDetails,
IconListNumbers,
IconMinus,
IconMoodSmile,
IconPhoto,
IconPilcrow,
@@ -26,6 +31,14 @@ const Icons: Record<string, IconComponent> = {
'Heading 1': IconH1,
'Heading 2': IconH2,
'Heading 3': IconH3,
'Heading 4': IconH4,
'Heading 5': IconH5,
'Heading 6': IconH6,
'Toggle Heading 1': IconH1,
'Toggle Heading 2': IconH2,
'Toggle Heading 3': IconH3,
'Toggle List': IconListDetails,
Divider: IconMinus,
Quote: IconBlockquote,
'Numbered List': IconListNumbers,
'Bullet List': IconList,
@@ -41,10 +54,12 @@ const Icons: Record<string, IconComponent> = {
export const getSlashMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
const items: SuggestionItem[] = [
...getDefaultReactSlashMenuItems(editor).map((x) => ({
...x,
Icon: Icons[x.title],
})),
...getDefaultReactSlashMenuItems(editor)
.filter((item) => item.title !== 'File')
.map((x) => ({
...x,
Icon: Icons[x.title],
})),
{
title: 'File',
aliases: ['file', 'folder'],
@@ -1,99 +0,0 @@
import { type Block } from '@blocknote/core';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { DashboardColorSelectionMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu';
import { COLOR_PICKER_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/ColorPickerFloatingConfig';
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
import { extractColorFromProps } from '@/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
type DashboardBlockColorPickerProps = {
block: Block;
anchorElement: HTMLElement | null;
onClose: () => void;
onColorSelect: (
textColor: BlockNoteColor | undefined,
backgroundColor: string | undefined,
) => void;
};
const COLOR_PICKER_CLICK_OUTSIDE_ID = 'color-picker-click-outside';
export const DashboardBlockColorPicker = ({
block,
anchorElement,
onClose,
onColorSelect,
}: DashboardBlockColorPickerProps) => {
const menuRef = useRef<HTMLDivElement>(null);
const currentTextColor = extractColorFromProps(block.props, 'text');
const currentBackgroundColor = extractColorFromProps(
block.props,
'background',
);
const { refs, floatingStyles } = useFloating({
placement: 'right-start',
whileElementsMounted: autoUpdate,
elements: {
reference: anchorElement,
},
middleware: [
offset(COLOR_PICKER_FLOATING_CONFIG.offsetFromMenuItem),
flip(),
shift({
padding: COLOR_PICKER_FLOATING_CONFIG.boundaryPadding,
}),
],
});
useListenClickOutside({
refs: [menuRef],
excludedClickOutsideIds: [COLOR_PICKER_CLICK_OUTSIDE_ID],
callback: onClose,
listenerId: 'dashboard-block-color-picker',
});
const handleTextColorSelect = (color: BlockNoteColor) => {
onColorSelect(color, undefined);
};
const handleBackgroundColorSelect = (color: BlockNoteColor) => {
onColorSelect(undefined, color);
};
return (
<>
{createPortal(
<OverlayContainer
ref={(node) => {
refs.setFloating(node);
(menuRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}}
style={floatingStyles}
className="bn-ui-container"
data-click-outside-id={COLOR_PICKER_CLICK_OUTSIDE_ID}
>
<DashboardColorSelectionMenu
currentTextColor={currentTextColor}
currentBackgroundColor={currentBackgroundColor}
onTextColorSelect={handleTextColorSelect}
onBackgroundColorSelect={handleBackgroundColorSelect}
/>
</OverlayContainer>,
document.body,
)}
</>
);
};
@@ -1,174 +0,0 @@
import { type Block } from '@blocknote/core';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { useLingui } from '@lingui/react/macro';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { DashboardBlockColorPicker } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockColorPicker';
import { type DASHBOARD_BLOCK_SCHEMA } from '@/page-layout/widgets/standalone-rich-text/constants/DashboardBlockSchema';
import { DRAG_HANDLE_MENU_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
type DashboardBlockDragHandleMenuProps = {
editor: typeof DASHBOARD_BLOCK_SCHEMA.BlockNoteEditor;
block: Block;
anchorElement: HTMLElement | null;
boundaryElement?: HTMLElement | null;
onClose: () => void;
};
const DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID = 'drag-handle-menu-click-outside';
const StyledColorMenuItem = styled.div`
position: relative;
`;
export const DashboardBlockDragHandleMenu = ({
editor,
block,
anchorElement,
boundaryElement,
onClose,
}: DashboardBlockDragHandleMenuProps) => {
const { t } = useLingui();
const menuRef = useRef<HTMLDivElement>(null);
const [showColorPicker, setShowColorPicker] = useState(false);
const [colorMenuItemElement, setColorMenuItemElement] =
useState<HTMLDivElement | null>(null);
const { refs, floatingStyles } = useFloating({
placement: 'right-start',
whileElementsMounted: autoUpdate,
elements: {
reference: anchorElement,
},
middleware: [
offset(DRAG_HANDLE_MENU_FLOATING_CONFIG.offsetFromAnchor),
flip({
boundary: boundaryElement ?? undefined,
}),
shift({
boundary: boundaryElement ?? undefined,
}),
],
});
useListenClickOutside({
refs: [menuRef],
excludedClickOutsideIds: [DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID],
callback: () => {
if (!showColorPicker) {
onClose();
}
},
listenerId: 'portaled-drag-handle-menu',
});
const handleAddBlock = () => {
const insertedBlocks = editor.insertBlocks(
[{ type: 'paragraph' }],
block,
'after',
);
const insertedBlock = insertedBlocks[0];
if (isDefined(insertedBlock)) {
editor.setTextCursorPosition(insertedBlock);
}
editor.openSuggestionMenu('/');
onClose();
};
const handleDelete = () => {
editor.removeBlocks([block]);
onClose();
};
const handleColorClick = () => {
setShowColorPicker(true);
};
const handleColorPickerClose = () => {
setShowColorPicker(false);
};
const handleColorSelect = (
textColor: string | undefined,
backgroundColor: string | undefined,
) => {
editor.updateBlock(block, {
props: {
...(isDefined(textColor) && { textColor }),
...(isDefined(backgroundColor) && { backgroundColor }),
},
});
setShowColorPicker(false);
onClose();
};
return (
<>
{createPortal(
<OverlayContainer
ref={(node) => {
refs.setFloating(node);
(menuRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}}
style={floatingStyles}
className="bn-ui-container"
data-click-outside-id={DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID}
>
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPlus}
onClick={handleAddBlock}
accent="default"
text={t`Add Block`}
/>
<StyledColorMenuItem ref={setColorMenuItemElement}>
<MenuItem
LeftIcon={IconColorSwatch}
onClick={handleColorClick}
accent="default"
text={t`Change Color`}
/>
</StyledColorMenuItem>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text={t`Delete`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>,
document.body,
)}
{showColorPicker && isDefined(colorMenuItemElement) && (
<DashboardBlockColorPicker
anchorElement={colorMenuItemElement}
block={block}
onClose={handleColorPickerClose}
onColorSelect={handleColorSelect}
/>
)}
</>
);
};
@@ -1,153 +1,107 @@
import { useBlockNoteEditor, useUIPluginState } from '@blocknote/react';
import { useTheme } from '@emotion/react';
import { SuggestionMenu } from '@blocknote/core/extensions';
import {
BlockColorsItem,
DragHandleButton,
DragHandleMenu,
RemoveBlockItem,
SideMenu,
SideMenuController,
useBlockNoteEditor,
useComponentsContext,
} from '@blocknote/react';
import styled from '@emotion/styled';
import { autoUpdate, useFloating } from '@floating-ui/react';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { IconGripVertical } from 'twenty-ui/display';
import { DashboardBlockDragHandleMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu';
import { type DASHBOARD_BLOCK_SCHEMA } from '@/page-layout/widgets/standalone-rich-text/constants/DashboardBlockSchema';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { useLingui } from '@lingui/react/macro';
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
import { isDefined } from 'twenty-shared/utils';
import { CustomSideMenuOptions } from '@/blocknote-editor/components/CustomSideMenuOptions';
import { type DASHBOARD_BLOCK_SCHEMA } from '@/page-layout/widgets/standalone-rich-text/constants/DashboardBlockSchema';
type DashboardEditorSideMenuProps = {
editor: typeof DASHBOARD_BLOCK_SCHEMA.BlockNoteEditor;
boundaryElement?: HTMLElement | null;
};
const StyledSideMenuContainer = styled.div`
display: flex;
z-index: ${RootStackingContextZIndices.DropdownPortalBelowModal};
`;
const StyledDragHandleContainerWrapper = styled.div`
width: 20px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
const StyledDragHandleContainer = styled.div`
align-items: center;
cursor: grab;
display: flex;
height: 24px;
justify-content: center;
width: 18px;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.light};
&:hover {
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
color: ${({ theme }) => theme.font.color.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light},
${({ theme }) => theme.boxShadow.strong};
}
&:active {
cursor: grabbing;
}
`;
const StyledDivToCreateGap = styled.div`
width: ${({ theme }) => theme.spacing(2)};
`;
export const DashboardEditorSideMenu = ({
const DashboardAddBlockItem = ({
editor,
boundaryElement,
}: DashboardEditorSideMenuProps) => {
children,
}: {
editor: typeof DASHBOARD_BLOCK_SCHEMA.BlockNoteEditor;
children: React.ReactNode;
}) => {
const Components = useComponentsContext();
const blockNoteEditor = useBlockNoteEditor();
const theme = useTheme();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [dragHandleElement, setDragHandleElement] =
useState<HTMLDivElement | null>(null);
const state = useUIPluginState(
blockNoteEditor.sideMenu.onUpdate.bind(blockNoteEditor.sideMenu),
);
const virtualReference = isDefined(state?.referencePos)
? { getBoundingClientRect: () => state.referencePos }
: null;
const { refs, floatingStyles } = useFloating({
placement: 'left-start',
whileElementsMounted: autoUpdate,
elements: {
reference: virtualReference,
},
});
if (!state?.show || !isDefined(virtualReference)) {
if (!Components) {
return null;
}
const handleClick = () => {
blockNoteEditor.sideMenu.freezeMenu();
setIsMenuOpen(true);
};
const currentBlock = blockNoteEditor.getTextCursorPosition().block;
const handleDragStart = (event: React.DragEvent) => {
blockNoteEditor.sideMenu.blockDragStart(
{
dataTransfer: event.dataTransfer,
clientY: event.clientY,
},
state.block,
const insertedBlocks = editor.insertBlocks(
[{ type: 'paragraph' }],
currentBlock,
'after',
);
};
const handleDragEnd = () => {
blockNoteEditor.sideMenu.blockDragEnd();
};
const insertedBlock = insertedBlocks[0];
if (isDefined(insertedBlock)) {
editor.setTextCursorPosition(insertedBlock);
}
const handleCloseMenu = () => {
setIsMenuOpen(false);
blockNoteEditor.sideMenu.unfreezeMenu();
blockNoteEditor.getExtension(SuggestionMenu)?.openSuggestionMenu('/');
};
return (
<>
{createPortal(
<StyledSideMenuContainer
ref={refs.setFloating}
style={floatingStyles}
className="bn-ui-container"
>
<StyledDragHandleContainerWrapper>
<StyledDragHandleContainer
ref={setDragHandleElement}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleClick}
>
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
</StyledDragHandleContainer>
</StyledDragHandleContainerWrapper>
<StyledDivToCreateGap />
</StyledSideMenuContainer>,
document.body,
)}
{isMenuOpen && (
<DashboardBlockDragHandleMenu
editor={editor}
block={state.block}
anchorElement={dragHandleElement}
boundaryElement={boundaryElement}
onClose={handleCloseMenu}
/>
)}
</>
<Components.Generic.Menu.Item onClick={handleClick}>
{children}
</Components.Generic.Menu.Item>
);
};
export const DashboardEditorSideMenu = ({
editor,
}: DashboardEditorSideMenuProps) => {
const { t } = useLingui();
return (
<SideMenuController
sideMenu={() => (
<SideMenu>
<DragHandleButton
dragHandleMenu={() => (
<DragHandleMenu>
<DashboardAddBlockItem editor={editor}>
<CustomSideMenuOptions
LeftIcon={IconPlus}
text={t`Add Block`}
Variant="normal"
/>
</DashboardAddBlockItem>
<BlockColorsItem>
<CustomSideMenuOptions
LeftIcon={IconColorSwatch}
text={t`Change Color`}
Variant="normal"
/>
</BlockColorsItem>
<RemoveBlockItem>
<CustomSideMenuOptions
LeftIcon={IconTrash}
text={t`Delete`}
Variant="danger"
/>
</RemoveBlockItem>
</DragHandleMenu>
)}
/>
<StyledDivToCreateGap />
</SideMenu>
)}
/>
);
};
@@ -1,21 +1,14 @@
import { type DefaultProps } from '@blocknote/core';
import {
BasicTextStyleButton,
BlockTypeSelect,
CreateLinkButton,
FormattingToolbar,
FormattingToolbarController,
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
useBlockNoteEditor,
useEditorContentOrSelectionChange,
useUIElementPositioning,
useUIPluginState,
} from '@blocknote/react';
import { useTheme } from '@emotion/react';
import { flip, FloatingPortal, offset, shift } from '@floating-ui/react';
import { useMemo, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { flip, offset, shift } from '@floating-ui/react';
import { DashboardFormattingToolbarColorButton } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton';
import { FORMATTING_TOOLBAR_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig';
@@ -28,163 +21,59 @@ const StyledToolbarContainer = styled.div`
}
`;
const textAlignmentToPlacement = (
textAlignment: DefaultProps['textAlignment'],
) => {
switch (textAlignment) {
case 'left':
return 'top-start';
case 'center':
return 'top';
case 'right':
return 'top-end';
default:
return 'top-start';
}
};
type DashboardFormattingToolbarProps = {
boundaryElement?: HTMLElement | null;
};
// This is a copy of the BlockNote's FormattingToolbarController component with customizations.
export const DashboardFormattingToolbar = ({
boundaryElement,
}: DashboardFormattingToolbarProps) => {
// eslint-disable-next-line twenty/no-state-useref
const toolbarContainerRef = useRef<HTMLDivElement | null>(null);
const editor = useBlockNoteEditor();
const theme = useTheme();
const colorScheme = theme.name === 'light' ? 'light' : 'dark';
const [placement, setPlacement] = useState<'top-start' | 'top' | 'top-end'>(
() => {
const block = editor.getTextCursorPosition().block;
if (!('textAlignment' in block.props)) {
return 'top-start';
}
return textAlignmentToPlacement(
block.props.textAlignment as DefaultProps['textAlignment'],
);
},
);
useEditorContentOrSelectionChange(() => {
const block = editor.getTextCursorPosition().block;
if (!('textAlignment' in block.props)) {
setPlacement('top-start');
} else {
setPlacement(
textAlignmentToPlacement(
block.props.textAlignment as DefaultProps['textAlignment'],
),
);
}
}, editor);
const state = useUIPluginState(
editor.formattingToolbar.onUpdate.bind(editor.formattingToolbar),
);
const isNodeSelection =
editor.prosemirrorView?.state.selection.toJSON().type === 'node';
const shouldShow = (state?.show && !isNodeSelection) || false;
const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning(
shouldShow,
state?.referencePos || null,
3000,
{
placement,
middleware: [
offset(FORMATTING_TOOLBAR_FLOATING_CONFIG.offsetFromSelection),
shift({
boundary: boundaryElement ?? undefined,
padding: FORMATTING_TOOLBAR_FLOATING_CONFIG.boundaryPadding,
}),
flip({
boundary: boundaryElement ?? undefined,
}),
],
onOpenChange: (open) => {
if (!open) {
editor.formattingToolbar.closeMenu();
editor.focus();
}
},
},
);
const combinedRef = useMemo(
() => (node: HTMLDivElement | null) => {
toolbarContainerRef.current = node;
if (typeof ref === 'function') {
ref(node);
}
},
[ref],
);
if (!isMounted || !isDefined(state)) {
return null;
}
if (!shouldShow && isDefined(toolbarContainerRef.current)) {
return (
<FloatingPortal>
<StyledToolbarContainer
className="bn-container bn-mantine bn-ui-container"
data-color-scheme={colorScheme}
data-mantine-color-scheme={colorScheme}
ref={combinedRef}
style={style}
dangerouslySetInnerHTML={{
__html: toolbarContainerRef.current.innerHTML,
}}
/>
</FloatingPortal>
);
}
return (
<FloatingPortal>
<StyledToolbarContainer
className="bn-container bn-mantine bn-ui-container"
data-color-scheme={colorScheme}
data-mantine-color-scheme={colorScheme}
ref={combinedRef}
style={style}
// eslint-disable-next-line react/jsx-props-no-spreading
{...getFloatingProps()}
>
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton textAlignment="center" key="textAlignCenterButton" />
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<DashboardFormattingToolbarColorButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
</StyledToolbarContainer>
</FloatingPortal>
<StyledToolbarContainer>
<FormattingToolbarController
formattingToolbar={() => (
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton
textAlignment="center"
key="textAlignCenterButton"
/>
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<DashboardFormattingToolbarColorButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
)}
floatingUIOptions={{
useFloatingOptions: {
middleware: [
offset(FORMATTING_TOOLBAR_FLOATING_CONFIG.offsetFromSelection),
shift({
boundary: boundaryElement ?? undefined,
padding: FORMATTING_TOOLBAR_FLOATING_CONFIG.boundaryPadding,
}),
flip({
boundary: boundaryElement ?? undefined,
}),
],
},
}}
/>
</StyledToolbarContainer>
);
};
@@ -1,7 +1,4 @@
import {
useBlockNoteEditor,
useEditorContentOrSelectionChange,
} from '@blocknote/react';
import { useActiveStyles, useBlockNoteEditor } from '@blocknote/react';
import styled from '@emotion/styled';
import {
autoUpdate,
@@ -47,18 +44,12 @@ export const DashboardFormattingToolbarColorButton = () => {
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [currentTextColor, setCurrentTextColor] =
useState<BlockNoteColor>('default');
const [currentBackgroundColor, setCurrentBackgroundColor] =
useState<BlockNoteColor>('default');
useEditorContentOrSelectionChange(() => {
const activeStyles = editor.getActiveStyles();
setCurrentTextColor(extractColorFromProps(activeStyles, 'text'));
setCurrentBackgroundColor(
extractColorFromProps(activeStyles, 'background'),
);
}, editor);
const activeStyles = useActiveStyles(editor);
const currentTextColor = extractColorFromProps(activeStyles, 'text');
const currentBackgroundColor = extractColorFromProps(
activeStyles,
'background',
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
@@ -1,4 +1,4 @@
import { filterSuggestionItems } from '@blocknote/core';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { BlockNoteView } from '@blocknote/mantine';
import { SuggestionMenuController } from '@blocknote/react';
import { useTheme } from '@emotion/react';
@@ -25,7 +25,7 @@ type DashboardsBlockEditorProps = {
};
// TODO: Refactor these BlockNote CSS overrides - some may be dead code now that we have custom components
// (DashboardBlockDragHandleMenu, DashboardEditorSideMenu, DashboardColorSelectionMenu).
// (DashboardEditorSideMenu, DashboardColorSelectionMenu).
// Test removing each selector and move necessary styles to appropriate components.
// eslint-disable-next-line twenty/no-hardcoded-colors
const StyledEditor = styled.div`
@@ -183,10 +183,7 @@ export const DashboardsBlockEditor = ({
{!readonly && (
<>
<DashboardFormattingToolbar boundaryElement={boundaryElement} />
<DashboardEditorSideMenu
editor={editor}
boundaryElement={boundaryElement}
/>
<DashboardEditorSideMenu editor={editor} />
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query) => {
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useState } from 'react';
import { type Attachment } from '@/activities/files/types/Attachment';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
@@ -68,16 +68,14 @@ export const StandaloneRichTextEditorContent = ({
store,
]);
const initialContent = useMemo(
() => filterSupportedBlocks(parseInitialBlocknote(currentBody)),
[currentBody],
const [initialContent] = useState(() =>
filterSupportedBlocks(parseInitialBlocknote(currentBody)),
);
const editor = useCreateBlockNote({
initialContent,
domAttributes: { editor: { class: 'editor' } },
schema: DASHBOARD_BLOCK_SCHEMA,
sideMenuDetection: 'editor',
placeholders: {
default: t`Enter text or type '/' for commands`,
},
@@ -115,7 +113,7 @@ export const StandaloneRichTextEditorContent = ({
handleAttachmentSync(newStringifiedBody, currentBody);
};
const handleBlockEditorFocus = useCallback(() => {
const handleBlockEditorFocus = () => {
pushFocusItemToFocusStack({
component: {
instanceId: widget.id,
@@ -124,14 +122,14 @@ export const StandaloneRichTextEditorContent = ({
focusId: widget.id,
globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG,
});
}, [pushFocusItemToFocusStack, widget.id]);
};
const handleBlockEditorBlur = useCallback(() => {
const handleBlockEditorBlur = () => {
handlePersistBody.flush();
removeFocusItemFromFocusStackById({
focusId: widget.id,
});
}, [handlePersistBody, removeFocusItemFromFocusStackById, widget.id]);
};
return (
<>
@@ -1,4 +0,0 @@
export const COLOR_PICKER_FLOATING_CONFIG = {
offsetFromMenuItem: 4,
boundaryPadding: 8,
};
@@ -1,3 +0,0 @@
export const DRAG_HANDLE_MENU_FLOATING_CONFIG = {
offsetFromAnchor: 4,
};
@@ -9,6 +9,9 @@ import {
IconH1,
IconH2,
IconH3,
IconH4,
IconH5,
IconH6,
IconList,
IconListCheck,
IconListNumbers,
@@ -21,6 +24,12 @@ const Icons: Record<string, IconComponent> = {
'Heading 1': IconH1,
'Heading 2': IconH2,
'Heading 3': IconH3,
'Heading 4': IconH4,
'Heading 5': IconH5,
'Heading 6': IconH6,
'Toggle Heading 1': IconH1,
'Toggle Heading 2': IconH2,
'Toggle Heading 3': IconH3,
Quote: IconBlockquote,
'Numbered List': IconListNumbers,
'Bullet List': IconList,
+1 -1
View File
@@ -30,7 +30,7 @@
"@aws-sdk/credential-providers": "3.998.0",
"@azure/msal-node": "^3.8.4",
"@babel/preset-env": "7.26.9",
"@blocknote/server-util": "^0.31.1",
"@blocknote/server-util": "^0.47.0",
"@clickhouse/client": "^1.11.0",
"@dagrejs/dagre": "^1.1.2",
"@e2b/code-interpreter": "^1.0.4",
@@ -5,19 +5,28 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { ServerBlockNoteEditor } from '@blocknote/server-util';
// Reuse a single ServerBlockNoteEditor across all calls to avoid
// the cost of dynamic import resolution + instance creation (~90ms) on every transform.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedServerBlockNoteEditor: any = null;
let cachedServerBlockNoteEditor: ServerBlockNoteEditor | null = null;
const getServerBlockNoteEditor = async () => {
if (!cachedServerBlockNoteEditor) {
const { ServerBlockNoteEditor } = await import('@blocknote/server-util');
// SWC compiles import() to require() in CJS mode, which breaks ESM-only
// transitive dependencies in @blocknote/core. Native import() resolves
// the ESM bundle path where the full chain works.
const nativeImport = new Function('specifier', 'return import(specifier)');
cachedServerBlockNoteEditor = ServerBlockNoteEditor.create();
const getServerBlockNoteEditor = async (): Promise<ServerBlockNoteEditor> => {
if (cachedServerBlockNoteEditor) {
return cachedServerBlockNoteEditor;
}
return cachedServerBlockNoteEditor;
const module = await nativeImport('@blocknote/server-util');
const editor: ServerBlockNoteEditor = module.ServerBlockNoteEditor.create();
cachedServerBlockNoteEditor = editor;
return editor;
};
export const transformRichTextV2Value = async (
@@ -200,6 +200,9 @@ export {
IconH1,
IconH2,
IconH3,
IconH4,
IconH5,
IconH6,
IconHandClick,
IconHandMove,
IconHeadphones,
+3
View File
@@ -275,6 +275,9 @@ export {
IconH1,
IconH2,
IconH3,
IconH4,
IconH5,
IconH6,
IconHandClick,
IconHandMove,
IconHeadphones,
+604 -530
View File
File diff suppressed because it is too large Load Diff