diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index 308d3c561a..7c6bd62f84 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -18,33 +18,27 @@ import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/com import { type Attachment } from '@/activities/files/types/Attachment'; import { type Note } from '@/activities/types/Note'; import { type Task } from '@/activities/types/Task'; -import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore'; -import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate'; -import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete'; -import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore'; import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; -import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; -import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; +import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig'; +import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync'; +import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote'; +import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; -import type { PartialBlock } from '@blocknote/core'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; import '@blocknote/react/style.css'; -import { isArray, isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; type ActivityRichTextEditorProps = { @@ -72,14 +66,6 @@ export const ActivityRichTextEditor = ({ (field) => field.name === 'bodyV2', ); - const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({ - objectNameSingular: CoreObjectNameSingular.Attachment, - }); - - const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({ - objectNameSingular: CoreObjectNameSingular.Attachment, - }); - const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { removeFocusItemFromFocusStackById } = useRemoveFocusItemFromFocusStackById(); @@ -102,18 +88,8 @@ export const ActivityRichTextEditor = ({ }, }); - const { fetchAllRecords: findSoftDeletedAttachments } = - useLazyFetchAllRecords({ - objectNameSingular: CoreObjectNameSingular.Attachment, - filter: { - deletedAt: { - is: 'NOT_NULL', - }, - }, - }); - const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Attachment, - }); + const { syncAttachments } = useAttachmentSync(attachments); + const { upsertActivity } = useUpsertActivity({ activityObjectNameSingular: activityObjectNameSingular, }); @@ -155,37 +131,13 @@ export const ActivityRichTextEditor = ({ }); }; - const prepareBody = (newStringifiedBody: string) => { - if (!newStringifiedBody) return newStringifiedBody; - - const body = JSON.parse(newStringifiedBody); - - const bodyWithSignedPayload = body.map((block: any) => { - if (block.type !== 'image' || !block.props.url) { - return block; - } - - const imageProps = block.props; - const imageUrl = new URL(imageProps.url); - - return { - ...block, - props: { - ...imageProps, - url: `${imageUrl.toString()}`, - }, - }; - }); - return JSON.stringify(bodyWithSignedPayload); - }; - const handlePersistBody = useCallback( (activityBody: string) => { if (!canCreateActivity) { setCanCreateActivity(true); } - persistBodyDebounced(prepareBody(activityBody)); + persistBodyDebounced(prepareBodyWithSignedUrls(activityBody)); }, [persistBodyDebounced, setCanCreateActivity, canCreateActivity], ); @@ -225,60 +177,17 @@ export const ActivityRichTextEditor = ({ handlePersistBody(newStringifiedBody); - const attachmentIdsToDelete = getActivityAttachmentIdsToDelete( + await syncAttachments( newStringifiedBody, - attachments, oldActivity?.bodyV2.blocknote, ); - - if (attachmentIdsToDelete.length > 0) { - await deleteAttachments({ - recordIdsToDelete: attachmentIdsToDelete, - }); - } - - const attachmentPathsToRestore = getActivityAttachmentPathsToRestore( - newStringifiedBody, - attachments, - ); - - if (attachmentPathsToRestore.length > 0) { - const softDeletedAttachments = - (await findSoftDeletedAttachments()) as Attachment[]; - - const attachmentIdsToRestore = filterAttachmentsToRestore( - attachmentPathsToRestore, - softDeletedAttachments, - ); - - await restoreAttachments({ - idsToRestore: attachmentIdsToRestore, - }); - } - const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate( - newStringifiedBody, - attachments, - ); - if (attachmentsToUpdate.length > 0) { - for (const attachmentToUpdate of attachmentsToUpdate) { - if (!attachmentToUpdate.id) continue; - await updateOneAttachment({ - idToUpdate: attachmentToUpdate.id, - updateOneRecordInput: { name: attachmentToUpdate.name }, - }); - } - } }, [ - attachments, activityId, cache, objectMetadataItemActivity, handlePersistBody, - deleteAttachments, - restoreAttachments, - findSoftDeletedAttachments, - updateOneAttachment, + syncAttachments, ], ); @@ -291,35 +200,14 @@ export const ActivityRichTextEditor = ({ }; const initialBody = useMemo(() => { - const blocknote = activity?.bodyV2?.blocknote; - - if ( - isDefined(activity) && - isNonEmptyString(blocknote) && - blocknote !== '{}' - ) { - let parsedBody: PartialBlock[] | undefined = undefined; - - // TODO: Remove this once we have removed the old rich text - try { - parsedBody = JSON.parse(blocknote); - } catch { - // eslint-disable-next-line no-console - console.warn( - `Failed to parse body for activity ${activityId}, for rich text version 'v2'`, - ); - // eslint-disable-next-line no-console - console.warn(blocknote); - } - - if (isArray(parsedBody) && parsedBody.length === 0) { - return undefined; - } - - return parsedBody; + if (!isDefined(activity)) { + return undefined; } - return undefined; + return parseInitialBlocknote( + activity?.bodyV2?.blocknote, + `Failed to parse body for activity ${activityId}, for rich text version 'v2'`, + ); }, [activity, activityId]); const handleEditorBuiltInUploadFile = async (file: File) => { @@ -431,10 +319,7 @@ export const ActivityRichTextEditor = ({ type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, }, focusId: activityId, - globalHotkeysConfig: { - enableGlobalHotkeysConflictingWithKeyboard: false, - enableGlobalHotkeysWithModifiers: true, - }, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, }); }, [recordTitleCellId, activityId, editor, pushFocusItemToFocusStack], diff --git a/packages/twenty-front/src/modules/command-menu/components/hooks/usePageLayoutHeaderInfo.ts b/packages/twenty-front/src/modules/command-menu/components/hooks/usePageLayoutHeaderInfo.ts index 4ec05e0f94..bbf43329d0 100644 --- a/packages/twenty-front/src/modules/command-menu/components/hooks/usePageLayoutHeaderInfo.ts +++ b/packages/twenty-front/src/modules/command-menu/components/hooks/usePageLayoutHeaderInfo.ts @@ -157,7 +157,6 @@ export const usePageLayoutHeaderInfo = ({ widgetInEditMode: undefined, }; } - default: return null; } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings.tsx index 8d99583633..191f890b0c 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings.tsx @@ -22,11 +22,7 @@ export const CommandMenuPageLayoutIframeSettings = () => { const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId); - if (!isDefined(widgetInEditMode)) { - throw new Error('Widget ID must be present while editing the widget'); - } - - const widgetConfiguration = widgetInEditMode.configuration; + const widgetConfiguration = widgetInEditMode?.configuration; const configUrl = widgetConfiguration && 'url' in widgetConfiguration @@ -38,6 +34,10 @@ export const CommandMenuPageLayoutIframeSettings = () => { ); const [urlError, setUrlError] = useState(''); + if (!isDefined(widgetInEditMode)) { + return null; + } + const validateUrl = (urlString: string): boolean => { const trimmedUrl = urlString.trim(); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx index 3ed89eb566..7953adbffe 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx @@ -1,23 +1,31 @@ import { CommandGroup } from '@/command-menu/components/CommandGroup'; import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { useCompanyDefaultChartConfig } from '@/page-layout/hooks/useCompanyDefaultChartConfig'; import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget'; import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget'; +import { useCreatePageLayoutStandaloneRichTextWidget } from '@/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { IconChartPie, IconFrame } from 'twenty-ui/display'; +import { + IconAlignBoxLeftTop, + IconChartPie, + IconFrame, +} from 'twenty-ui/display'; import { GraphType } from '~/generated-metadata/graphql'; export const CommandMenuPageLayoutWidgetTypeSelect = () => { const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { closeCommandMenu } = useCommandMenu(); + const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); const { buildBarChartFieldSelection } = useCompanyDefaultChartConfig(); @@ -28,6 +36,9 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => { const { createPageLayoutIframeWidget } = useCreatePageLayoutIframeWidget(pageLayoutId); + const { createPageLayoutStandaloneRichTextWidget } = + useCreatePageLayoutStandaloneRichTextWidget(pageLayoutId); + const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] = useRecoilComponentState( pageLayoutEditingWidgetIdComponentState, @@ -64,8 +75,23 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => { }); }; + const handleNavigateToRichTextSettings = () => { + if (!isDefined(pageLayoutEditingWidgetId)) { + const newWidget = createPageLayoutStandaloneRichTextWidget({ + blocknote: '', + markdown: null, + }); + setPageLayoutEditingWidgetId(newWidget.id); + } + + closeCommandMenu(); + }; + return ( - + { onClick={handleNavigateToIframeSettings} /> + + + + ); diff --git a/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts b/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts index 89ddde7499..1d2b3e0754 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts @@ -6,4 +6,8 @@ export const WIDGET_SIZES: Partial> = { default: { w: 6, h: 6 }, minimum: { w: 4, h: 5 }, }, + [WidgetType.STANDALONE_RICH_TEXT]: { + default: { w: 4, h: 4 }, + minimum: { w: 1, h: 1 }, + }, }; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget.ts new file mode 100644 index 0000000000..754db3d13a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget.ts @@ -0,0 +1,129 @@ +import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; +import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab'; +import { createDefaultStandaloneRichTextWidget } from '@/page-layout/utils/createDefaultStandaloneRichTextWidget'; +import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; +import { + type PageLayoutWidget, + type RichTextV2Body, + WidgetType, +} from '~/generated/graphql'; + +export const useCreatePageLayoutStandaloneRichTextWidget = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const activeTabIdState = useRecoilComponentCallbackState( + activeTabIdComponentState, + getTabListInstanceIdFromPageLayoutId(pageLayoutId), + ); + + const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const pageLayoutDraggedAreaState = useRecoilComponentCallbackState( + pageLayoutDraggedAreaComponentState, + pageLayoutId, + ); + + const pageLayoutDraftState = useRecoilComponentCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const createPageLayoutStandaloneRichTextWidget = useRecoilCallback( + ({ snapshot, set }) => + (body: RichTextV2Body): PageLayoutWidget => { + const activeTabId = snapshot.getLoadable(activeTabIdState).getValue(); + + if (!isDefined(activeTabId)) { + throw new Error( + 'A tab must be selected to create a new rich text widget', + ); + } + + const allTabLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + + const pageLayoutDraggedArea = snapshot + .getLoadable(pageLayoutDraggedAreaState) + .getValue(); + + const widgetId = uuidv4(); + const richTextSize = WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!; + const defaultRichTextSize = richTextSize.default; + const minimumSize = richTextSize.minimum; + const position = getDefaultWidgetPosition( + pageLayoutDraggedArea, + defaultRichTextSize, + minimumSize, + ); + + const newWidget = createDefaultStandaloneRichTextWidget( + widgetId, + activeTabId, + + body, + { + row: position.y, + column: position.x, + rowSpan: position.h, + columnSpan: position.w, + }, + ); + + const newLayout = { + i: widgetId, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + minW: minimumSize.w, + minH: minimumSize.h, + }; + + const updatedLayouts = getUpdatedTabLayouts( + allTabLayouts, + activeTabId, + newLayout, + ); + + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget), + })); + + set(pageLayoutDraggedAreaState, null); + + return newWidget; + }, + [ + activeTabIdState, + pageLayoutCurrentLayoutsState, + pageLayoutDraftState, + pageLayoutDraggedAreaState, + ], + ); + + return { createPageLayoutStandaloneRichTextWidget }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts b/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts index 88200a259c..dc80c58d13 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useEndPageLayoutDragSelection.ts @@ -2,6 +2,7 @@ import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layo import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilCallback } from 'recoil'; @@ -26,6 +27,11 @@ export const useEndPageLayoutDragSelection = ( pageLayoutId, ); + const pageLayoutEditingWidgetIdState = useRecoilComponentCallbackState( + pageLayoutEditingWidgetIdComponentState, + pageLayoutId, + ); + const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); const endPageLayoutDragSelection = useRecoilCallback( @@ -42,6 +48,7 @@ export const useEndPageLayoutDragSelection = ( if (isDefined(draggedBounds)) { set(pageLayoutDraggedAreaState, draggedBounds); + set(pageLayoutEditingWidgetIdState, null); navigatePageLayoutCommandMenu({ commandMenuPage: CommandMenuPages.PageLayoutWidgetTypeSelect, @@ -54,6 +61,7 @@ export const useEndPageLayoutDragSelection = ( [ navigatePageLayoutCommandMenu, pageLayoutDraggedAreaState, + pageLayoutEditingWidgetIdState, pageLayoutSelectedCellsState, ], ); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts index c66b304a75..5598976fce 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutToTabLayouts.test.ts @@ -1,3 +1,4 @@ +import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes'; import { type PageLayout } from '@/page-layout/types/PageLayout'; import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts'; import { @@ -86,4 +87,54 @@ describe('convertPageLayoutToTabLayouts', () => { }, }); }); + + it('should apply STANDALONE_RICH_TEXT minimum size constraints', () => { + const pageLayout: PageLayout = { + id: 'page-layout-1', + name: 'Page Layout 1', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: 'object-metadata-1', + tabs: [ + { + id: 'tab-1', + title: 'Tab 1', + position: 0, + pageLayoutId: 'page-layout-1', + widgets: [ + { + __typename: 'PageLayoutWidget', + id: 'rich-text-widget', + pageLayoutTabId: 'tab-1', + title: 'Rich Text', + type: WidgetType.STANDALONE_RICH_TEXT, + configuration: { + body: { blocknote: '[]' }, + }, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + objectMetadataId: null, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }, + ], + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + deletedAt: null, + }; + + const result = convertPageLayoutToTabLayouts(pageLayout); + const richTextMinSize = + WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!.minimum; + + expect(result['tab-1'].desktop[0]).toMatchObject({ + i: 'rich-text-widget', + minW: richTextMinSize.w, + minH: richTextMinSize.h, + }); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultStandaloneRichTextWidget.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultStandaloneRichTextWidget.test.ts new file mode 100644 index 0000000000..0e918b1b71 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/createDefaultStandaloneRichTextWidget.test.ts @@ -0,0 +1,45 @@ +import { WidgetType } from '~/generated/graphql'; +import { createDefaultStandaloneRichTextWidget } from '../createDefaultStandaloneRichTextWidget'; + +describe('createDefaultStandaloneRichTextWidget', () => { + it('should create a standalone rich text widget with correct structure', () => { + const widget = createDefaultStandaloneRichTextWidget( + 'widget-1', + 'tab-1', + { blocknote: '[{"type":"paragraph","content":"Test"}]' }, + { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + ); + + expect(widget).toMatchObject({ + __typename: 'PageLayoutWidget', + id: 'widget-1', + pageLayoutTabId: 'tab-1', + type: WidgetType.STANDALONE_RICH_TEXT, + title: 'Untitled Rich Text', + configuration: { + body: { blocknote: '[{"type":"paragraph","content":"Test"}]' }, + }, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }); + }); + + it('should use provided objectMetadataId or default to null', () => { + const withObjectId = createDefaultStandaloneRichTextWidget( + 'w1', + 't1', + { blocknote: '[]' }, + { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, + 'object-1', + ); + + const withoutObjectId = createDefaultStandaloneRichTextWidget( + 'w2', + 't1', + { blocknote: '[]' }, + { row: 0, column: 0, rowSpan: 1, columnSpan: 1 }, + ); + + expect(withObjectId.objectMetadataId).toBe('object-1'); + expect(withoutObjectId.objectMetadataId).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts index ccf2c23f8c..fa93ecaceb 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutToTabLayouts.ts @@ -39,6 +39,13 @@ export const convertPageLayoutToTabLayouts = ( minH = iframeMinimumSize.h; } + if (widget.type === WidgetType.STANDALONE_RICH_TEXT) { + const richTextMinimumSize = + WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!.minimum; + minW = richTextMinimumSize.w; + minH = richTextMinimumSize.h; + } + return { i: widget.id, x: widget.gridPosition.column, diff --git a/packages/twenty-front/src/modules/page-layout/utils/createDefaultStandaloneRichTextWidget.ts b/packages/twenty-front/src/modules/page-layout/utils/createDefaultStandaloneRichTextWidget.ts new file mode 100644 index 0000000000..f5cb4d17de --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/createDefaultStandaloneRichTextWidget.ts @@ -0,0 +1,30 @@ +import { + type GridPosition, + type PageLayoutWidget, + type RichTextV2Body, + WidgetType, +} from '~/generated/graphql'; + +export const createDefaultStandaloneRichTextWidget = ( + id: string, + pageLayoutTabId: string, + body: RichTextV2Body, + gridPosition: GridPosition, + objectMetadataId?: string | null, +): PageLayoutWidget => { + return { + __typename: 'PageLayoutWidget', + id, + pageLayoutTabId, + title: 'Untitled Rich Text', + type: WidgetType.STANDALONE_RICH_TEXT, + configuration: { + body, + }, + gridPosition, + objectMetadataId: objectMetadataId ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx index 68d0737606..656858b825 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx @@ -7,6 +7,7 @@ import { FileWidget } from '@/page-layout/widgets/files/components/FileWidget'; import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer'; import { IframeWidget } from '@/page-layout/widgets/iframe/components/IframeWidget'; import { NoteWidget } from '@/page-layout/widgets/notes/components/NoteWidget'; +import { StandaloneRichTextWidget } from '@/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidget'; import { TaskWidget } from '@/page-layout/widgets/tasks/components/TaskWidget'; import { TimelineWidget } from '@/page-layout/widgets/timeline/components/TimelineWidget'; import { WorkflowRunWidget } from '@/page-layout/widgets/workflow/components/WorkflowRunWidget'; @@ -61,6 +62,9 @@ export const WidgetContentRenderer = ({ case WidgetType.WORKFLOW_RUN: return ; + case WidgetType.STANDALONE_RICH_TEXT: + return ; + default: return null; } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx index 20115d03b8..5e97ab77d4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx @@ -21,6 +21,7 @@ import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component import { useTheme } from '@emotion/react'; import { type MouseEvent } from 'react'; import { IconLock } from 'twenty-ui/display'; +import { WidgetType } from '~/generated/graphql'; type WidgetRendererProps = { widget: PageLayoutWidget; @@ -60,7 +61,13 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { const { currentPageLayout } = useCurrentPageLayoutOrThrow(); - const showHeader = layoutMode !== 'canvas' && !isInPinnedTab; + // TODO: when we have more widgets without headers, we should use a more generic approach to hide the header + // each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never') + const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT; + const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode; + + const showHeader = + layoutMode !== 'canvas' && !isInPinnedTab && !hideRichTextHeader; const handleClick = () => { handleEditWidget({ @@ -95,6 +102,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { return ( 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(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( + { + refs.setFloating(node); + (menuRef as React.MutableRefObject).current = + node; + }} + style={floatingStyles} + className="bn-ui-container" + data-click-outside-id={COLOR_PICKER_CLICK_OUTSIDE_ID} + > + + , + document.body, + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx new file mode 100644 index 0000000000..9e073de9ee --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx @@ -0,0 +1,174 @@ +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 { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { DashboardBlockColorPicker } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockColorPicker'; +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 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(null); + const [showColorPicker, setShowColorPicker] = useState(false); + const [colorMenuItemElement, setColorMenuItemElement] = + useState(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( + { + refs.setFloating(node); + (menuRef as React.MutableRefObject).current = + node; + }} + style={floatingStyles} + className="bn-ui-container" + data-click-outside-id={DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID} + > + + + + + + + + + + , + document.body, + )} + + {showColorPicker && isDefined(colorMenuItemElement) && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon.tsx new file mode 100644 index 0000000000..d2efe008f9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon.tsx @@ -0,0 +1,77 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor'; + +const StyledColorIcon = styled.div<{ + textColorValue: string; + backgroundColorValue: string; +}>` + background-color: ${({ backgroundColorValue }) => backgroundColorValue}; + border-radius: ${({ theme }) => theme.border.radius.xs}; + color: ${({ textColorValue }) => textColorValue}; + font-size: 12px; + font-weight: ${({ theme }) => theme.font.weight.medium}; + height: 16px; + line-height: 16px; + pointer-events: none; + text-align: center; + width: 16px; +`; + +type DashboardColorIconProps = { + textColor?: BlockNoteColor; + backgroundColor?: BlockNoteColor; +}; + +export const DashboardColorIcon = ({ + textColor, + backgroundColor, +}: DashboardColorIconProps) => { + const theme = useTheme(); + + const getThemeColorForTextColor = (color: BlockNoteColor): string => { + if (color === 'default') { + return 'inherit'; + } + return theme.color[color] ?? 'inherit'; + }; + + const getThemeColorForBackgroundColor = (color: BlockNoteColor): string => { + if (color === 'default') { + return 'transparent'; + } + + const backgroundColorMap: Record< + Exclude, + string + > = { + gray: theme.color.gray3, + brown: theme.color.brown3, + red: theme.color.red3, + orange: theme.color.orange3, + yellow: theme.color.yellow3, + green: theme.color.green3, + blue: theme.color.blue3, + purple: theme.color.purple3, + pink: theme.color.pink3, + }; + + return backgroundColorMap[color]; + }; + + return ( + + A + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu.tsx new file mode 100644 index 0000000000..e6df21df1c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu.tsx @@ -0,0 +1,103 @@ +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { IconCheck } from 'twenty-ui/display'; + +import { DashboardColorIcon } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon'; +import { BLOCKNOTE_COLOR_DISPLAY_NAMES } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColorDisplayNames'; +import { BLOCKNOTE_COLORS } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors'; +import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { useTheme } from '@emotion/react'; + +const StyledColorMenuItem = styled.div` + align-items: center; + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + min-height: ${({ theme }) => theme.spacing(6)}; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; + + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } +`; + +const StyledColorName = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; +`; + +const StyledCheckIcon = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.primary}; + display: flex; + height: ${({ theme }) => theme.spacing(4)}; + justify-content: center; + width: ${({ theme }) => theme.spacing(4)}; +`; + +type DashboardColorSelectionMenuProps = { + currentTextColor: string; + currentBackgroundColor: string; + onTextColorSelect: (color: BlockNoteColor) => void; + onBackgroundColorSelect: (color: BlockNoteColor) => void; +}; + +export const DashboardColorSelectionMenu = ({ + currentTextColor, + currentBackgroundColor, + onTextColorSelect, + onBackgroundColorSelect, +}: DashboardColorSelectionMenuProps) => { + const { t } = useLingui(); + const theme = useTheme(); + return ( + + + + + {BLOCKNOTE_COLORS.map((colorName) => ( + onTextColorSelect(colorName)} + > + + + {BLOCKNOTE_COLOR_DISPLAY_NAMES[colorName]} + + {currentTextColor === colorName && ( + + + + )} + + ))} + + + + + {BLOCKNOTE_COLORS.map((colorName) => ( + onBackgroundColorSelect(colorName)} + > + + + {BLOCKNOTE_COLOR_DISPLAY_NAMES[colorName]} + + {currentBackgroundColor === colorName && ( + + + + )} + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx new file mode 100644 index 0000000000..0458cde19f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx @@ -0,0 +1,151 @@ +import { useBlockNoteEditor, useUIPluginState } from '@blocknote/react'; +import { useTheme } from '@emotion/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 { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { DashboardBlockDragHandleMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu'; +import { isDefined } from 'twenty-shared/utils'; + +type DashboardEditorSideMenuProps = { + editor: typeof BLOCK_SCHEMA.BlockNoteEditor; + boundaryElement?: HTMLElement | null; +}; + +const StyledSideMenuContainer = styled.div` + display: flex; +`; + +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 = ({ + editor, + boundaryElement, +}: DashboardEditorSideMenuProps) => { + const blockNoteEditor = useBlockNoteEditor(); + const theme = useTheme(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [dragHandleElement, setDragHandleElement] = + useState(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)) { + return null; + } + + const handleClick = () => { + blockNoteEditor.sideMenu.freezeMenu(); + setIsMenuOpen(true); + }; + + const handleDragStart = (event: React.DragEvent) => { + blockNoteEditor.sideMenu.blockDragStart( + { + dataTransfer: event.dataTransfer, + clientY: event.clientY, + }, + state.block, + ); + }; + + const handleDragEnd = () => { + blockNoteEditor.sideMenu.blockDragEnd(); + }; + + const handleCloseMenu = () => { + setIsMenuOpen(false); + blockNoteEditor.sideMenu.unfreezeMenu(); + }; + + return ( + <> + {createPortal( + + + + + + + + , + document.body, + )} + + {isMenuOpen && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar.tsx new file mode 100644 index 0000000000..698309c3f3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar.tsx @@ -0,0 +1,190 @@ +import { type DefaultProps } from '@blocknote/core'; +import { + BasicTextStyleButton, + BlockTypeSelect, + CreateLinkButton, + FormattingToolbar, + 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 { DashboardFormattingToolbarColorButton } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton'; +import { FORMATTING_TOOLBAR_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig'; +import styled from '@emotion/styled'; + +const StyledToolbarContainer = styled.div` + & .bn-formatting-toolbar .mantine-Button-root { + height: 24px; + min-height: 24px; + } +`; + +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 @nx/workspace-no-state-useref + const toolbarContainerRef = useRef(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 ( + + + + ); + } + + return ( + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton.tsx new file mode 100644 index 0000000000..a5f7987e43 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton.tsx @@ -0,0 +1,152 @@ +import { + useBlockNoteEditor, + useEditorContentOrSelectionChange, +} from '@blocknote/react'; +import styled from '@emotion/styled'; +import { + autoUpdate, + flip, + offset, + shift, + useFloating, +} from '@floating-ui/react'; +import { useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { DashboardColorIcon } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon'; +import { DashboardColorSelectionMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu'; +import { COLOR_DROPDOWN_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/ColorDropdownFloatingConfig'; +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'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledColorButton = styled.button` + align-items: center; + background: transparent; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: pointer; + display: flex; + height: 24px; + justify-content: center; + padding: ${({ theme }) => theme.spacing(1)}; + width: 24px; + + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } +`; + +const COLOR_BUTTON_CLICK_OUTSIDE_ID = 'color-button-click-outside'; + +export const DashboardFormattingToolbarColorButton = () => { + const editor = useBlockNoteEditor(); + const buttonRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + + const [currentTextColor, setCurrentTextColor] = + useState('default'); + const [currentBackgroundColor, setCurrentBackgroundColor] = + useState('default'); + + useEditorContentOrSelectionChange(() => { + const activeStyles = editor.getActiveStyles(); + setCurrentTextColor(extractColorFromProps(activeStyles, 'text')); + setCurrentBackgroundColor( + extractColorFromProps(activeStyles, 'background'), + ); + }, editor); + + const { refs, floatingStyles } = useFloating({ + placement: 'bottom-start', + whileElementsMounted: autoUpdate, + middleware: [ + offset(COLOR_DROPDOWN_FLOATING_CONFIG.offsetFromButton), + flip(), + shift({ padding: COLOR_DROPDOWN_FLOATING_CONFIG.boundaryPadding }), + ], + }); + + useListenClickOutside({ + refs: [menuRef], + excludedClickOutsideIds: [COLOR_BUTTON_CLICK_OUTSIDE_ID], + callback: () => setIsOpen(false), + listenerId: 'custom-color-style-button', + }); + + const handleButtonClick = () => { + if (isDefined(buttonRef.current)) { + refs.setReference(buttonRef.current); + } + setIsOpen(!isOpen); + }; + + const applyTextColor = (color: string) => { + if (color === 'default') { + editor.removeStyles({ textColor: color }); + } else { + editor.addStyles({ textColor: color }); + } + setTimeout(() => editor.focus()); + }; + + const applyBackgroundColor = (color: string) => { + if (color === 'default') { + editor.removeStyles({ backgroundColor: color }); + } else { + editor.addStyles({ backgroundColor: color }); + } + setTimeout(() => editor.focus()); + }; + + const handleTextColorSelect = (color: BlockNoteColor) => { + applyTextColor(color); + setIsOpen(false); + }; + + const handleBackgroundColorSelect = (color: BlockNoteColor) => { + applyBackgroundColor(color); + setIsOpen(false); + }; + + return ( + <> + + + + + {isOpen && + createPortal( + { + refs.setFloating(node); + ( + menuRef as React.MutableRefObject + ).current = node; + }} + style={floatingStyles} + className="bn-ui-container" + data-click-outside-id={COLOR_BUTTON_CLICK_OUTSIDE_ID} + > + + , + document.body, + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx new file mode 100644 index 0000000000..a65810fadc --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx @@ -0,0 +1,198 @@ +import { filterSuggestionItems } from '@blocknote/core'; +import { BlockNoteView } from '@blocknote/mantine'; +import { SuggestionMenuController } from '@blocknote/react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { type ClipboardEvent } from 'react'; + +import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu'; +import { DashboardEditorSideMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu'; +import { DashboardFormattingToolbar } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar'; +import { + CustomSlashMenu, + type SuggestionItem, +} from '@/ui/input/editor/components/CustomSlashMenu'; + +interface DashboardsBlockEditorProps { + editor: typeof BLOCK_SCHEMA.BlockNoteEditor; + onFocus?: () => void; + onBlur?: () => void; + onPaste?: (event: ClipboardEvent) => void; + onChange?: () => void; + readonly?: boolean; + boundaryElement?: HTMLElement | null; +} + +// TODO: Refactor these BlockNote CSS overrides - some may be dead code now that we have custom components +// (DashboardBlockDragHandleMenu, DashboardEditorSideMenu, DashboardColorSelectionMenu). +// Test removing each selector and move necessary styles to appropriate components. +// eslint-disable-next-line @nx/workspace-no-hardcoded-colors +const StyledEditor = styled.div` + width: 100%; + + & .editor { + background: transparent; + font-size: 13px; + color: ${({ theme }) => theme.font.color.primary}; + } + & .editor [class^='_inlineContent']:before { + color: ${({ theme }) => theme.font.color.tertiary}; + font-style: normal !important; + } + & .editor .bn-inline-content:has(> .ProseMirror-trailingBreak):before { + font-style: normal; + } + & .mantine-ActionIcon-icon { + height: 20px; + width: 20px; + background: transparent; + } + & .bn-container .bn-drag-handle { + width: 20px; + height: 20px; + } + & .bn-block-outer { + line-height: 1.4; + } + & .bn-block-content[data-content-type='checkListItem'] > div > div { + display: flex; + align-items: center; + } + & .bn-drag-handle-menu { + background: ${({ theme }) => theme.background.transparent.secondary}; + backdrop-filter: ${({ theme }) => theme.blur.medium}; + box-shadow: + 0px 2px 4px rgba(0, 0, 0, 0.04), + 2px 4px 16px rgba(0, 0, 0, 0.12); + min-width: 160px; + min-height: 96px; + padding: 4px; + border-radius: 8px; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + } + + & .bn-editor { + padding-inline: 0px; + } + + & .bn-inline-content { + width: 100%; + } + + & .bn-container .bn-suggestion-menu-item:hover { + background-color: blue; + } + + & .bn-suggestion-menu { + padding: 4px; + border-radius: 8px; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + background: ${({ theme }) => theme.background.transparent.secondary}; + backdrop-filter: ${({ theme }) => theme.blur.medium}; + } + + & .mantine-Menu-item { + background-color: transparent; + min-width: 152px; + min-height: 32px; + + font-style: normal; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + color: ${({ theme }) => theme.font.color.secondary}; + } + & .mantine-ActionIcon-root:hover { + box-shadow: + 0px 0px 4px rgba(0, 0, 0, 0.08), + 0px 2px 4px rgba(0, 0, 0, 0.04); + background: ${({ theme }) => theme.background.transparent.primary}; + backdrop-filter: blur(20px); + border: 1px solid ${({ theme }) => theme.border.color.light}; + } + & .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg { + height: 16px; + width: 16px; + } + + & .bn-mantine .bn-side-menu > [draggable='true'] { + margin-bottom: 5px; + } + & .bn-color-picker-dropdown { + margin-left: 8px; + } + + & .bn-inline-content a { + color: ${({ theme }) => theme.color.blue}; + } + + & .bn-inline-content code { + font-family: monospace; + color: ${({ theme }) => theme.font.color.danger}; + padding: 2px 4px; + border-radius: 4px; + border: 1px solid ${({ theme }) => theme.font.color.extraLight}; + font-size: 0.9rem; + background-color: ${({ theme }) => theme.background.transparent.light}; + } +`; + +export const DashboardsBlockEditor = ({ + editor, + onFocus, + onBlur, + onChange, + onPaste, + readonly, + boundaryElement, +}: DashboardsBlockEditorProps) => { + const theme = useTheme(); + const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark'; + + const handleFocus = () => { + onFocus?.(); + }; + + const handleBlur = () => { + onBlur?.(); + }; + + const handleChange = () => { + onChange?.(); + }; + + const handlePaste = (event: ClipboardEvent) => { + onPaste?.(event); + }; + + return ( + + + + + { + const items = getSlashMenu(editor); + return filterSuggestionItems(items, query); + }} + suggestionMenuComponent={CustomSlashMenu} + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidget.tsx new file mode 100644 index 0000000000..0d3d05790c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidget.tsx @@ -0,0 +1,187 @@ +import { useCallback, useMemo, useRef } from 'react'; + +import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; +import { type Attachment } from '@/activities/files/types/Attachment'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { DashboardsBlockEditor } from '@/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor'; +import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig'; +import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync'; +import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote'; +import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls'; +import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import '@blocknote/core/fonts/inter.css'; +import '@blocknote/mantine/style.css'; +import { useCreateBlockNote } from '@blocknote/react'; +import '@blocknote/react/style.css'; +import styled from '@emotion/styled'; +import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; +import { + PageLayoutType, + type StandaloneRichTextConfiguration, +} from '~/generated/graphql'; + +const StyledContainer = styled.div<{ isPageLayoutInEditMode?: boolean }>` + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; + padding-left: ${({ theme, isPageLayoutInEditMode }) => + isPageLayoutInEditMode ? theme.spacing(5) : 0}; +`; + +type StandaloneRichTextWidgetProps = { + widget: PageLayoutWidget; +}; + +export const StandaloneRichTextWidget = ({ + widget, +}: StandaloneRichTextWidgetProps) => { + const containerElementRef = useRef(null); + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + + const editingWidgetId = useRecoilComponentValue( + pageLayoutEditingWidgetIdComponentState, + ); + + const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(); + const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext(); + const { uploadAttachmentFile } = useUploadAttachmentFile(); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const isDashboard = layoutType === PageLayoutType.DASHBOARD; + const dashboardId = isDashboard ? targetRecordIdentifier?.id : undefined; + + const configuration = widget.configuration as + | StandaloneRichTextConfiguration + | undefined; + + const currentBody = configuration?.body?.blocknote ?? ''; + + const { records: attachments } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Attachment, + filter: isDefined(dashboardId) + ? { dashboardId: { eq: dashboardId } } + : undefined, + skip: !isDefined(dashboardId), + }); + + const { syncAttachments } = useAttachmentSync(attachments); + + const handleUploadAttachment = async (file: File) => { + if (!isDefined(dashboardId)) return { attachmentAbsoluteURL: '' }; + + return await uploadAttachmentFile(file, { + id: dashboardId, + targetObjectNameSingular: CoreObjectNameSingular.Dashboard, + }); + }; + + const handleEditorBuiltInUploadFile = async (file: File) => { + const { attachmentAbsoluteURL } = await handleUploadAttachment(file); + return attachmentAbsoluteURL; + }; + + const initialContent = useMemo(() => { + if (isDefined(configuration) && 'body' in configuration) { + return parseInitialBlocknote(configuration.body?.blocknote); + } + return undefined; + }, [configuration]); + + const editor = useCreateBlockNote({ + initialContent, + domAttributes: { editor: { class: 'editor' } }, + schema: BLOCK_SCHEMA, + uploadFile: handleEditorBuiltInUploadFile, + sideMenuDetection: 'editor', + }); + + const handlePersistBody = useDebouncedCallback((blocknote: string) => { + updatePageLayoutWidget(widget.id, { + configuration: { + body: { + blocknote, + markdown: null, + }, + }, + }); + }, 300); + + const handleAttachmentSync = useDebouncedCallback( + async (newStringifiedBody: string, previousBody: string) => { + await syncAttachments(newStringifiedBody, previousBody); + }, + 500, + ); + + const handleEditorChange = () => { + const newStringifiedBody = JSON.stringify(editor.document) ?? ''; + const preparedBody = prepareBodyWithSignedUrls(newStringifiedBody); + + handlePersistBody(preparedBody); + handleAttachmentSync(newStringifiedBody, currentBody); + }; + + const isThisWidgetBeingEdited = editingWidgetId === widget.id; + const isEditable = isPageLayoutInEditMode && isThisWidgetBeingEdited; + + const handleBlockEditorFocus = useCallback(() => { + pushFocusItemToFocusStack({ + component: { + instanceId: widget.id, + type: FocusComponentType.STANDALONE_RICH_TEXT_WIDGET, + }, + focusId: widget.id, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, + }); + }, [pushFocusItemToFocusStack, widget.id]); + + const handleBlockEditorBlur = useCallback(() => { + removeFocusItemFromFocusStackById({ + focusId: widget.id, + }); + }, [removeFocusItemFromFocusStackById, widget.id]); + + if (!isDefined(dashboardId)) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColorDisplayNames.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColorDisplayNames.ts new file mode 100644 index 0000000000..590da9cea4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColorDisplayNames.ts @@ -0,0 +1,14 @@ +import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor'; + +export const BLOCKNOTE_COLOR_DISPLAY_NAMES: Record = { + default: 'Default', + gray: 'Gray', + brown: 'Brown', + red: 'Red', + orange: 'Orange', + yellow: 'Yellow', + green: 'Green', + blue: 'Blue', + purple: 'Purple', + pink: 'Pink', +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors.ts new file mode 100644 index 0000000000..c550d6246c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors.ts @@ -0,0 +1,12 @@ +export const BLOCKNOTE_COLORS = [ + 'default', + 'gray', + 'brown', + 'red', + 'orange', + 'yellow', + 'green', + 'blue', + 'purple', + 'pink', +] as const; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorDropdownFloatingConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorDropdownFloatingConfig.ts new file mode 100644 index 0000000000..03c5a52763 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorDropdownFloatingConfig.ts @@ -0,0 +1,4 @@ +export const COLOR_DROPDOWN_FLOATING_CONFIG = { + offsetFromButton: 8, + boundaryPadding: 8, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorPickerFloatingConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorPickerFloatingConfig.ts new file mode 100644 index 0000000000..829c4167fb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/ColorPickerFloatingConfig.ts @@ -0,0 +1,4 @@ +export const COLOR_PICKER_FLOATING_CONFIG = { + offsetFromMenuItem: 4, + boundaryPadding: 8, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig.ts new file mode 100644 index 0000000000..11785d867d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig.ts @@ -0,0 +1,3 @@ +export const DRAG_HANDLE_MENU_FLOATING_CONFIG = { + offsetFromAnchor: 4, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig.ts new file mode 100644 index 0000000000..c3dad72bcd --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig.ts @@ -0,0 +1,4 @@ +export const FORMATTING_TOOLBAR_FLOATING_CONFIG = { + offsetFromSelection: 10, + boundaryPadding: 8, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/types/BlockNoteColor.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/types/BlockNoteColor.ts new file mode 100644 index 0000000000..aa5ae344b7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/types/BlockNoteColor.ts @@ -0,0 +1,3 @@ +import { type BLOCKNOTE_COLORS } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors'; + +export type BlockNoteColor = (typeof BLOCKNOTE_COLORS)[number]; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/__tests__/extractColorFromProps.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/__tests__/extractColorFromProps.test.ts new file mode 100644 index 0000000000..79928866e7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/__tests__/extractColorFromProps.test.ts @@ -0,0 +1,65 @@ +import { extractColorFromProps } from '../extractColorFromProps'; + +describe('extractColorFromProps', () => { + describe('text color extraction', () => { + it('should return textColor when present and is a string', () => { + const props = { textColor: 'red' }; + expect(extractColorFromProps(props, 'text')).toBe('red'); + }); + + it('should return default when textColor is not a string', () => { + const props = { textColor: 123 }; + expect(extractColorFromProps(props, 'text')).toBe('default'); + }); + + it('should return default when textColor is missing', () => { + const props = {}; + expect(extractColorFromProps(props, 'text')).toBe('default'); + }); + + it('should return default when textColor is null', () => { + const props = { textColor: null }; + expect(extractColorFromProps(props, 'text')).toBe('default'); + }); + + it('should return default when textColor is undefined', () => { + const props = { textColor: undefined }; + expect(extractColorFromProps(props, 'text')).toBe('default'); + }); + }); + + describe('background color extraction', () => { + it('should return backgroundColor when present and is a string', () => { + const props = { backgroundColor: 'blue' }; + expect(extractColorFromProps(props, 'background')).toBe('blue'); + }); + + it('should return default when backgroundColor is not a string', () => { + const props = { backgroundColor: { value: 'blue' } }; + expect(extractColorFromProps(props, 'background')).toBe('default'); + }); + + it('should return default when backgroundColor is missing', () => { + const props = {}; + expect(extractColorFromProps(props, 'background')).toBe('default'); + }); + + it('should return default when backgroundColor is null', () => { + const props = { backgroundColor: null }; + expect(extractColorFromProps(props, 'background')).toBe('default'); + }); + }); + + describe('edge cases', () => { + it('should not confuse textColor and backgroundColor', () => { + const props = { textColor: 'red', backgroundColor: 'blue' }; + expect(extractColorFromProps(props, 'text')).toBe('red'); + expect(extractColorFromProps(props, 'background')).toBe('blue'); + }); + + it('should handle empty string as valid color', () => { + const props = { textColor: '' }; + expect(extractColorFromProps(props, 'text')).toBe(''); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps.ts b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps.ts new file mode 100644 index 0000000000..b8ede63127 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps.ts @@ -0,0 +1,12 @@ +import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor'; +import { isString } from '@sniptt/guards'; + +export const extractColorFromProps = ( + props: Record, + colorType: 'text' | 'background', +): BlockNoteColor => { + const propertyName = colorType === 'text' ? 'textColor' : 'backgroundColor'; + return isString(props[propertyName]) + ? (props[propertyName] as BlockNoteColor) + : 'default'; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx index cfd4401bdb..598b01020c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx @@ -10,6 +10,7 @@ const StyledWidgetCard = styled.div<{ isEditing: boolean; isDragging: boolean; isResizing: boolean; + headerLess?: boolean; }>` box-sizing: border-box; display: flex; @@ -26,13 +27,14 @@ const StyledWidgetCard = styled.div<{ isDragging, isResizing, onClick, + headerLess, }) => { if (variant === 'dashboard' && !isEditable) { return css` background: ${theme.background.secondary}; border: 1px solid ${theme.border.color.light}; border-radius: ${theme.border.radius.md}; - padding: ${theme.spacing(2)}; + padding: ${headerLess ? 0 : theme.spacing(2)}; gap: ${theme.spacing(2)}; `; } @@ -42,7 +44,7 @@ const StyledWidgetCard = styled.div<{ background: ${theme.background.secondary}; border: 1px solid ${theme.border.color.light}; border-radius: ${theme.border.radius.md}; - padding: ${theme.spacing(2)}; + padding: ${headerLess ? 0 : theme.spacing(2)}; gap: ${theme.spacing(2)}; ${!isDragging && diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx index 6361bb5395..e85cf4b775 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx @@ -6,7 +6,8 @@ const StyledWidgetCardContent = styled.div<{ variant: WidgetCardVariant }>` align-items: center; display: flex; height: 100%; - width: 100%; + flex: 1; + overflow: hidden; justify-content: center; box-sizing: border-box; diff --git a/packages/twenty-front/src/modules/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig.ts b/packages/twenty-front/src/modules/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig.ts new file mode 100644 index 0000000000..bcef171285 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig.ts @@ -0,0 +1,4 @@ +export const BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG = { + enableGlobalHotkeysConflictingWithKeyboard: false, + enableGlobalHotkeysWithModifiers: true, +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts b/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts new file mode 100644 index 0000000000..a31cb31b1d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts @@ -0,0 +1,89 @@ +import { type Attachment } from '@/activities/files/types/Attachment'; +import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore'; +import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate'; +import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete'; +import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; + +export const useAttachmentSync = (attachments: Attachment[]) => { + const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({ + objectNameSingular: CoreObjectNameSingular.Attachment, + }); + + const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({ + objectNameSingular: CoreObjectNameSingular.Attachment, + }); + + const { fetchAllRecords: findSoftDeletedAttachments } = + useLazyFetchAllRecords({ + objectNameSingular: CoreObjectNameSingular.Attachment, + filter: { + deletedAt: { + is: 'NOT_NULL', + }, + }, + }); + + const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Attachment, + }); + + const syncAttachments = async ( + newBody: string, + previousBody?: string | null, + ) => { + if (!newBody) return; + + const previousBodyOrEmptyArray = previousBody?.trim() ? previousBody : '[]'; + + const attachmentIdsToDelete = getActivityAttachmentIdsToDelete( + newBody, + attachments, + previousBodyOrEmptyArray, + ); + + if (attachmentIdsToDelete.length > 0) { + await deleteAttachments({ + recordIdsToDelete: attachmentIdsToDelete, + }); + } + + const attachmentPathsToRestore = getActivityAttachmentPathsToRestore( + newBody, + attachments, + ); + + if (attachmentPathsToRestore.length > 0) { + const softDeletedAttachments = + (await findSoftDeletedAttachments()) as Attachment[]; + + const attachmentIdsToRestore = filterAttachmentsToRestore( + attachmentPathsToRestore, + softDeletedAttachments ?? [], + ); + + await restoreAttachments({ + idsToRestore: attachmentIdsToRestore, + }); + } + + const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate( + newBody, + attachments, + ); + + for (const attachmentToUpdate of attachmentsToUpdate) { + if (!attachmentToUpdate.id || !attachmentToUpdate.name) continue; + await updateOneAttachment({ + idToUpdate: attachmentToUpdate.id, + updateOneRecordInput: { name: attachmentToUpdate.name }, + }); + } + }; + + return { syncAttachments }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts b/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts new file mode 100644 index 0000000000..10a86d1cba --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts @@ -0,0 +1,47 @@ +import { parseInitialBlocknote } from '../parseInitialBlocknote'; + +describe('parseInitialBlocknote', () => { + it('should parse valid JSON array string', () => { + const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]); + const result = parseInitialBlocknote(input); + expect(result).toEqual([{ type: 'paragraph', content: 'test' }]); + }); + + it('should return undefined for empty string', () => { + expect(parseInitialBlocknote('')).toBeUndefined(); + }); + + it('should return undefined for null', () => { + expect(parseInitialBlocknote(null)).toBeUndefined(); + }); + + it('should return undefined for undefined', () => { + expect(parseInitialBlocknote(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty object string "{}"', () => { + expect(parseInitialBlocknote('{}')).toBeUndefined(); + }); + + it('should return undefined for invalid JSON', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + expect(parseInitialBlocknote('invalid json')).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should return undefined for empty array', () => { + expect(parseInitialBlocknote('[]')).toBeUndefined(); + }); + + it('should return undefined for non-array JSON', () => { + expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined(); + }); + + it('should use custom log context when parsing fails', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + parseInitialBlocknote('invalid', 'Custom context'); + expect(consoleSpy).toHaveBeenCalledWith('Custom context'); + consoleSpy.mockRestore(); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts b/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts new file mode 100644 index 0000000000..9c6cebd2d8 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts @@ -0,0 +1,46 @@ +import { prepareBodyWithSignedUrls } from '../prepareBodyWithSignedUrls'; + +describe('prepareBodyWithSignedUrls', () => { + it('should return empty string as-is', () => { + expect(prepareBodyWithSignedUrls('')).toBe(''); + }); + + it('should parse and re-stringify blocks', () => { + const input = JSON.stringify([{ type: 'paragraph', content: 'text' }]); + const result = JSON.parse(prepareBodyWithSignedUrls(input)); + expect(result).toEqual([{ type: 'paragraph', content: 'text' }]); + }); + + it('should pass through non-image blocks unchanged', () => { + const blocks = [ + { type: 'paragraph', content: 'text' }, + { type: 'heading', content: 'title' }, + { type: 'bulletListItem', content: 'item' }, + ]; + const result = JSON.parse( + prepareBodyWithSignedUrls(JSON.stringify(blocks)), + ); + expect(result).toEqual(blocks); + }); + + it('should skip image blocks without props', () => { + const input = JSON.stringify([{ type: 'image' }]); + const result = JSON.parse(prepareBodyWithSignedUrls(input)); + expect(result).toEqual([{ type: 'image' }]); + }); + + it('should skip image blocks without url in props', () => { + const input = JSON.stringify([{ type: 'image', props: { alt: 'test' } }]); + const result = JSON.parse(prepareBodyWithSignedUrls(input)); + expect(result).toEqual([{ type: 'image', props: { alt: 'test' } }]); + }); + + it('should process image blocks with valid URLs', () => { + const input = JSON.stringify([ + { type: 'image', props: { url: 'https://example.com/image.png' } }, + ]); + const result = JSON.parse(prepareBodyWithSignedUrls(input)); + expect(result[0].type).toBe('image'); + expect(result[0].props.url).toContain('example.com'); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/parseInitialBlocknote.ts b/packages/twenty-front/src/modules/ui/input/editor/utils/parseInitialBlocknote.ts new file mode 100644 index 0000000000..1392a28c43 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/utils/parseInitialBlocknote.ts @@ -0,0 +1,29 @@ +import type { PartialBlock } from '@blocknote/core'; +import { isArray, isNonEmptyString } from '@sniptt/guards'; + +export const parseInitialBlocknote = ( + blocknote?: string | null, + logContext?: string, +): PartialBlock[] | undefined => { + if (isNonEmptyString(blocknote) && blocknote !== '{}') { + let parsedBody: PartialBlock[] | undefined = undefined; + + // TODO: Remove this once we have removed the old rich text + try { + parsedBody = JSON.parse(blocknote); + } catch { + // eslint-disable-next-line no-console + console.warn(logContext ?? `Failed to parse blocknote body`); + // eslint-disable-next-line no-console + console.warn(blocknote); + } + + if (!isArray(parsedBody) || parsedBody.length === 0) { + return undefined; + } + + return parsedBody; + } + + return undefined; +}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/prepareBodyWithSignedUrls.ts b/packages/twenty-front/src/modules/ui/input/editor/utils/prepareBodyWithSignedUrls.ts new file mode 100644 index 0000000000..64c455322b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/editor/utils/prepareBodyWithSignedUrls.ts @@ -0,0 +1,30 @@ +import type { PartialBlock } from '@blocknote/core'; + +// TODO: This function is extracted but its not doing what it is supposed to do. It is not signing the urls. It is just parsing the image urls. +// tracking issue - https://github.com/twentyhq/twenty/issues/8351 +export const prepareBodyWithSignedUrls = ( + newStringifiedBody: string, +): string => { + if (!newStringifiedBody) return newStringifiedBody; + + const body: PartialBlock[] = JSON.parse(newStringifiedBody); + + const bodyWithSignedPayload = body.map((block) => { + if (block.type !== 'image' || !block.props?.url) { + return block; + } + + const imageUrl = block.props.url; + const parsedImageUrl = new URL(imageUrl); + + return { + ...block, + props: { + ...block.props, + url: parsedImageUrl.toString(), + }, + }; + }); + + return JSON.stringify(bodyWithSignedPayload); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts index e7e59c8313..b3ab031fef 100644 --- a/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts +++ b/packages/twenty-front/src/modules/ui/utilities/focus/types/FocusComponentType.ts @@ -12,6 +12,7 @@ export enum FocusComponentType { FORM_FIELD_INPUT = 'form-field-input', RECORD_BOARD_CARD = 'record-board-card', ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor', + STANDALONE_RICH_TEXT_WIDGET = 'standalone-rich-text-widget', KEYBOARD_SHORTCUT_MENU = 'keyboard-shortcut-menu', DIALOG = 'dialog', } diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index bb9d2bbfa5..4bfa0af53e 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -3,6 +3,7 @@ export { IconNumber123 as Icon123, IconAlertCircle, IconAlertTriangle, + IconAlignBoxLeftTop, IconAlignCenter, IconAlignLeft, IconAlignRight, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index 923d2e5e1a..f21719d1e8 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -68,6 +68,7 @@ export { Icon123, IconAlertCircle, IconAlertTriangle, + IconAlignBoxLeftTop, IconAlignCenter, IconAlignLeft, IconAlignRight,