Refactored WorkflowSendEmailBody to FormAdvancedTextFieldInput (#15157)
Step 1 towards fixing issue #14976 Refactored WorkflowSendEmailBody to FormAdvancedTextFieldInput For its reusability across application - `useEmailEditor` is transformed to `useAdvancedTextEditor`: A reuseable hook for managing the text editor state and functionality. - `WorkflowSendEmailBody` is transformed to `FormAdvancedTextFieldEditor`: A wrapper component for the advanced text editor. - `WorkflowSendEmailBody` is transformed to `FormAdvancedTextFieldInput`: A component integrating the advanced text editor into forms. - `ImageBubbleMenu`, `LinkBubbleMenu`, and `TextBubbleMenu`: Contextual menus for image, link, and text formatting options. are moved to ui/components for access in FormAdvancedTextFieldInput Additionally, the email action workflow component was updated to utilize the new reuseable text editor, enhancing the user experience for composing emails. This update also includes storybook entries for the new components to facilitate testing and documentation. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
+53
-5
@@ -1,23 +1,28 @@
|
||||
import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
|
||||
import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope';
|
||||
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
||||
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
||||
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
|
||||
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
|
||||
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
|
||||
@@ -28,6 +33,9 @@ import { type JsonValue } from 'type-fest';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
const EMAIL_EDITOR_MIN_HEIGHT = 340;
|
||||
const EMAIL_EDITOR_MAX_WIDTH = 600;
|
||||
|
||||
type WorkflowEditActionSendEmailProps = {
|
||||
action: WorkflowSendEmailAction;
|
||||
actionOptions:
|
||||
@@ -55,10 +63,15 @@ export const WorkflowEditActionSendEmail = ({
|
||||
const { getIcon } = useIcons();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { triggerApisOAuth } = useTriggerApisOAuth();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { uploadAttachmentFile } = useUploadAttachmentFile();
|
||||
|
||||
const workflowVisualizerWorkflowId = useRecoilComponentValue(
|
||||
workflowVisualizerWorkflowIdComponentState,
|
||||
);
|
||||
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
|
||||
|
||||
const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`;
|
||||
|
||||
const [formData, setFormData] = useState<SendEmailFormData>({
|
||||
@@ -115,7 +128,6 @@ export const WorkflowEditActionSendEmail = ({
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
@@ -154,6 +166,25 @@ export const WorkflowEditActionSendEmail = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleUploadAttachment = async (file: File) => {
|
||||
if (!isDefined(workflowVisualizerWorkflowId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, {
|
||||
id: workflowVisualizerWorkflowId,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
});
|
||||
|
||||
return attachmentAbsoluteURL;
|
||||
};
|
||||
|
||||
const handleImageUploadError = (error: Error, file: File) => {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to upload image: `.concat(file.name),
|
||||
});
|
||||
};
|
||||
|
||||
const filter: { or: object[] } = {
|
||||
or: [
|
||||
{
|
||||
@@ -285,16 +316,33 @@ export const WorkflowEditActionSendEmail = ({
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
<WorkflowSendEmailBody
|
||||
action={action}
|
||||
<FormAdvancedTextFieldInput
|
||||
label="Body"
|
||||
placeholder="Enter email body"
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.body}
|
||||
onChange={(body) => {
|
||||
onChange={(body: string) => {
|
||||
handleFieldChange('body', body);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
enableFullScreen={true}
|
||||
fullScreenBreadcrumbs={[
|
||||
{
|
||||
children: workflow?.name?.trim() || t`Untitled Workflow`,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: headerTitle,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: t`Email Editor`,
|
||||
},
|
||||
]}
|
||||
onImageUpload={handleUploadAttachment}
|
||||
onImageUploadError={handleImageUploadError}
|
||||
minHeight={EMAIL_EDITOR_MIN_HEIGHT}
|
||||
maxWidth={EMAIL_EDITOR_MAX_WIDTH}
|
||||
/>
|
||||
</WorkflowStepBody>
|
||||
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
|
||||
import { ImageBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu';
|
||||
import { LinkBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu';
|
||||
import { TextBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import styled from '@emotion/styled';
|
||||
import { EditorContent, type Editor } from '@tiptap/react';
|
||||
|
||||
const EMAIL_EDITOR_MIN_HEIGHT = 340;
|
||||
const EMAIL_EDITOR_MAX_WIDTH = 600;
|
||||
|
||||
const StyledEditorContainer = styled.div<{
|
||||
readonly?: boolean;
|
||||
}>`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.editor-content {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: ${EMAIL_EDITOR_MIN_HEIGHT}px;
|
||||
max-width: ${EMAIL_EDITOR_MAX_WIDTH}px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
color: ${({ theme, readonly }) =>
|
||||
readonly ? theme.font.color.light : theme.font.color.primary};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
border: none !important;
|
||||
|
||||
p.is-editor-empty:first-of-type::before {
|
||||
${FORM_FIELD_PLACEHOLDER_STYLES}
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.variable-tag {
|
||||
background-color: ${({ theme }) => theme.color.blue10};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkflowEmailEditorProps = {
|
||||
readonly: boolean | undefined;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const WorkflowEmailEditor = ({
|
||||
readonly,
|
||||
editor,
|
||||
}: WorkflowEmailEditorProps) => {
|
||||
return (
|
||||
<StyledEditorContainer readonly={readonly}>
|
||||
<EditorContent className="editor-content" editor={editor} />
|
||||
<ImageBubbleMenu editor={editor} />
|
||||
<TextBubbleMenu editor={editor} />
|
||||
<LinkBubbleMenu editor={editor} />
|
||||
</StyledEditorContainer>
|
||||
);
|
||||
};
|
||||
-261
@@ -1,261 +0,0 @@
|
||||
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
|
||||
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
|
||||
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
|
||||
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useId, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconMaximize } from 'twenty-ui/display';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledWorkflowSendEmailBodyContainer = styled(FormFieldInputContainer)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledWorkflowSendEmailFieldContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledWorkflowSendEmailBodyInnerContainer = styled.div`
|
||||
flex-grow: 1;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEmailEditorActionButtonContainer = styled.div`
|
||||
position: absolute;
|
||||
top: ${({ theme }) => theme.spacing(0)};
|
||||
right: ${({ theme }) => theme.spacing(7.5)};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledFullScreenEmailEditorContainer = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)`
|
||||
background-color: 'transparent';
|
||||
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkflowSendEmailBodyProps = {
|
||||
action: WorkflowSendEmailAction;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
defaultValue: string | undefined | null;
|
||||
onChange: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
};
|
||||
|
||||
export const WorkflowSendEmailBody = ({
|
||||
action,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onChange,
|
||||
readonly,
|
||||
VariablePicker,
|
||||
}: WorkflowSendEmailBodyProps) => {
|
||||
const instanceId = useId();
|
||||
const isMobile = useIsMobile();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const { uploadAttachmentFile } = useUploadAttachmentFile();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
const workflowVisualizerWorkflowId = useRecoilComponentValue(
|
||||
workflowVisualizerWorkflowIdComponentState,
|
||||
);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
|
||||
|
||||
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
|
||||
|
||||
const handleUploadAttachment = async (file: File) => {
|
||||
if (!isDefined(workflowVisualizerWorkflowId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, {
|
||||
id: workflowVisualizerWorkflowId,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
});
|
||||
|
||||
return attachmentAbsoluteURL;
|
||||
};
|
||||
|
||||
const handleImageUploadError = (error: Error, file: File) => {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to upload image: `.concat(file.name),
|
||||
});
|
||||
};
|
||||
|
||||
const editor = useEmailEditor(
|
||||
{
|
||||
placeholder: placeholder ?? 'Enter email',
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate: (editor) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
onChange(JSON.stringify(jsonContent));
|
||||
},
|
||||
onFocus: () => {
|
||||
pushFocusItemToFocusStack({
|
||||
focusId: instanceId,
|
||||
component: {
|
||||
type: FocusComponentType.FORM_FIELD_INPUT,
|
||||
instanceId: instanceId,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
onBlur: () => {
|
||||
removeFocusItemFromFocusStackById({ focusId: instanceId });
|
||||
},
|
||||
onImageUpload: handleUploadAttachment,
|
||||
onImageUploadError: handleImageUploadError,
|
||||
},
|
||||
[isFullScreen],
|
||||
);
|
||||
|
||||
const handleEnterFullScreen = () => {
|
||||
setIsFullScreen(true);
|
||||
};
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
setIsFullScreen(false);
|
||||
};
|
||||
|
||||
const handleVariableTagInsert = (variableName: string) => {
|
||||
if (!isDefined(editor)) {
|
||||
throw new Error(
|
||||
'Expected the editor to be defined when a variable is selected',
|
||||
);
|
||||
}
|
||||
|
||||
editor.commands.insertVariableTag(variableName);
|
||||
};
|
||||
|
||||
const breadcrumbLinks: BreadcrumbProps['links'] = [
|
||||
{
|
||||
children: workflow?.name?.trim() || t`Untitled Workflow`,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: headerTitle,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: t`Email Editor`,
|
||||
},
|
||||
];
|
||||
|
||||
const { renderFullScreenModal } = useFullScreenModal({
|
||||
links: breadcrumbLinks,
|
||||
onClose: handleExitFullScreen,
|
||||
hasClosePageButton: !isMobile,
|
||||
});
|
||||
|
||||
const fullScreenOverlay = renderFullScreenModal(
|
||||
<div data-globally-prevent-click-outside="true">
|
||||
<StyledFullScreenEmailEditorContainer>
|
||||
<WorkflowEmailEditor editor={editor} readonly={readonly} />
|
||||
</StyledFullScreenEmailEditorContainer>
|
||||
</div>,
|
||||
isFullScreen,
|
||||
);
|
||||
|
||||
if (!isDefined(editor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWorkflowSendEmailBodyContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
|
||||
<StyledWorkflowSendEmailFieldContainer>
|
||||
<StyledWorkflowSendEmailBodyInnerContainer>
|
||||
{!isFullScreen && (
|
||||
<WorkflowEmailEditor editor={editor} readonly={readonly} />
|
||||
)}
|
||||
|
||||
<StyledEmailEditorActionButtonContainer>
|
||||
{!readonly && !isFullScreen && (
|
||||
<StyledFullScreenButtonContainer
|
||||
isUnfolded={false}
|
||||
transparentBackground
|
||||
onClick={handleEnterFullScreen}
|
||||
>
|
||||
<IconMaximize size={theme.icon.size.md} />
|
||||
</StyledFullScreenButtonContainer>
|
||||
)}
|
||||
</StyledEmailEditorActionButtonContainer>
|
||||
|
||||
{VariablePicker && !readonly ? (
|
||||
<VariablePicker
|
||||
instanceId={instanceId}
|
||||
multiline={true}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
/>
|
||||
) : null}
|
||||
</StyledWorkflowSendEmailBodyInnerContainer>
|
||||
</StyledWorkflowSendEmailFieldContainer>
|
||||
{hint && <InputHint>{hint}</InputHint>}
|
||||
{error && <InputErrorHelper>{error}</InputErrorHelper>}
|
||||
</StyledWorkflowSendEmailBodyContainer>
|
||||
|
||||
{fullScreenOverlay}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-276
@@ -1,276 +0,0 @@
|
||||
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
|
||||
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
|
||||
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
const EditorWrapper = ({
|
||||
readonly = false,
|
||||
placeholder = 'Enter email content...',
|
||||
defaultValue = null,
|
||||
onUpdate = fn(),
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | null;
|
||||
onUpdate?: (content: string) => void;
|
||||
}) => {
|
||||
const editor = useEmailEditor({
|
||||
placeholder,
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate: (editor) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
onUpdate(JSON.stringify(jsonContent));
|
||||
},
|
||||
onImageUpload: async (file: File) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`;
|
||||
},
|
||||
onImageUploadError: (_error: Error, _file: File) => {
|
||||
// Handle image upload error
|
||||
},
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return <div>Loading editor...</div>;
|
||||
}
|
||||
|
||||
return <WorkflowEmailEditor editor={editor} readonly={readonly} />;
|
||||
};
|
||||
|
||||
const meta: Meta<typeof EditorWrapper> = {
|
||||
title: 'Modules/Workflow/Actions/SendEmail/EmailEditor',
|
||||
component: EditorWrapper,
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
decorators: [
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
RouterDecorator,
|
||||
WorkspaceDecorator,
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EditorWrapper>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithContent: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'John',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ',',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This is a sample email with ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'italic' }],
|
||||
text: 'italic',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' and ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'underline' }],
|
||||
text: 'underlined',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' text.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeadings: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Welcome Email' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Getting Started' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Thank you for joining us! Here are some next steps:',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: 'text', text: 'Next Steps' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: '1. Complete your profile' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLinks: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Visit our ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://twenty.com' } }],
|
||||
text: 'website',
|
||||
},
|
||||
{ type: 'text', text: ' for more information.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Contact us at ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'mailto:support@twenty.com' } },
|
||||
],
|
||||
text: 'support@twenty.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithVariableTags: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Dear ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{firstName}}' },
|
||||
},
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Your order ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{orderNumber}}' },
|
||||
},
|
||||
{ type: 'text', text: ' has been shipped!' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
readonly: true,
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'Read-only mode',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This editor is in read-only mode and cannot be edited.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
placeholder: 'Start typing your email content...',
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
onUpdate: fn(),
|
||||
placeholder: 'Try typing, formatting text, or uploading images...',
|
||||
},
|
||||
};
|
||||
-320
@@ -1,320 +0,0 @@
|
||||
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
import { graphql, HttpResponse } from 'msw';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
|
||||
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||
|
||||
const DEFAULT_ACTION: WorkflowSendEmailAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Send Email',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: false,
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const RICH_CONTENT_ACTION: WorkflowSendEmailAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Welcome Email',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: 'test-account-id',
|
||||
email: 'user@example.com',
|
||||
subject: 'Welcome to Twenty!',
|
||||
body: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Welcome to Twenty!' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Dear ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{firstName}}' },
|
||||
},
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Thank you for joining us! We are ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'excited',
|
||||
},
|
||||
{ type: 'text', text: ' to have you on board.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Visit our ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'https://twenty.com' } },
|
||||
],
|
||||
text: 'website',
|
||||
},
|
||||
{ type: 'text', text: ' to get started.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkflowSendEmailBody> = {
|
||||
title: 'Modules/Workflow/Actions/SendEmail/EmailBody',
|
||||
component: WorkflowSendEmailBody,
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
...graphqlMocks.handlers,
|
||||
graphql.query('FindManyWorkflows', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
workflows: [
|
||||
{
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Test Workflow',
|
||||
__typename: 'Workflow',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
RouterDecorator,
|
||||
WorkspaceDecorator,
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByRole('textbox')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRichContent: Story = {
|
||||
args: {
|
||||
action: RICH_CONTENT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
|
||||
expect(await canvas.findByText('excited')).toBeVisible();
|
||||
expect(await canvas.findByText('website')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
action: RICH_CONTENT_ACTION,
|
||||
label: 'Email Body (Read Only)',
|
||||
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
|
||||
onChange: fn(),
|
||||
readonly: true,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body (Read Only)')).toBeVisible();
|
||||
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
error: 'Email body is required',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByText('Email body is required')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
hint: 'You can use variables, format text, and add images to your email content.',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'You can use variables, format text, and add images to your email content.',
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Start typing to see the editor in action...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
|
||||
const editor = await canvas.findByRole('textbox');
|
||||
expect(editor).toBeVisible();
|
||||
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, 'Hello World!');
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutVariablePicker: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByRole('textbox')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongContent: Story = {
|
||||
args: {
|
||||
action: {
|
||||
...DEFAULT_ACTION,
|
||||
settings: {
|
||||
...DEFAULT_ACTION.settings,
|
||||
input: {
|
||||
...DEFAULT_ACTION.settings.input,
|
||||
body: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
};
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import {
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconTrash,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
type ImageBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const ImageBubbleMenu = ({ editor }: ImageBubbleMenuProps) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
align: ctx.editor.getAttributes('image').align || 'left',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
editor.chain().focus().deleteSelection().run();
|
||||
};
|
||||
|
||||
const alignmentActions = [
|
||||
{
|
||||
align: 'left',
|
||||
Icon: IconAlignLeft,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'left' })
|
||||
.run(),
|
||||
isActive: state.align === 'left',
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
Icon: IconAlignCenter,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'center' })
|
||||
.run(),
|
||||
isActive: state.align === 'center',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
Icon: IconAlignRight,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'right' })
|
||||
.run(),
|
||||
isActive: state.align === 'right',
|
||||
},
|
||||
];
|
||||
|
||||
const handleShouldShow = () => {
|
||||
return editor.isActive('image');
|
||||
};
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="image-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
{alignmentActions.map(({ align, Icon, onClick, isActive }) => (
|
||||
<BubbleMenuIconButton
|
||||
key={`image-align-${align}`}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
/>
|
||||
))}
|
||||
<BubbleMenuIconButton
|
||||
key="image-delete"
|
||||
Icon={IconTrash}
|
||||
onClick={handleDelete}
|
||||
isActive={false}
|
||||
/>
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
|
||||
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import { IconExternalLink, IconLinkOff } from 'twenty-ui/display';
|
||||
|
||||
type LinkBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const LinkBubbleMenu = ({ editor }: LinkBubbleMenuProps) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
linkHref: ctx.editor.getAttributes('link').href || '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const handleShouldShow = () => {
|
||||
return editor.isActive('link');
|
||||
};
|
||||
|
||||
const menuActions = [
|
||||
{
|
||||
Icon: IconExternalLink,
|
||||
onClick: () => {
|
||||
window.open(state.linkHref, '_blank');
|
||||
},
|
||||
},
|
||||
{
|
||||
Icon: IconLinkOff,
|
||||
onClick: () =>
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="link-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
|
||||
{menuActions.map(({ Icon, onClick }) => {
|
||||
return (
|
||||
<BubbleMenuIconButton
|
||||
key={Icon.name || Icon.displayName || 'unknown'}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import type { IconComponent } from 'twenty-ui/display';
|
||||
import { FloatingIconButton } from 'twenty-ui/input';
|
||||
|
||||
type BubbleMenuIconButtonProps = {
|
||||
className?: string;
|
||||
Icon?: IconComponent;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const StyledBubbleMenuIconButton = styled(FloatingIconButton)`
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.spacing(1.5)};
|
||||
width: ${({ theme }) => theme.spacing(6)};
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
export const BubbleMenuIconButton = ({
|
||||
className,
|
||||
Icon,
|
||||
disabled = false,
|
||||
focus = false,
|
||||
onClick,
|
||||
isActive,
|
||||
}: BubbleMenuIconButtonProps) => {
|
||||
return (
|
||||
<StyledBubbleMenuIconButton
|
||||
className={className}
|
||||
Icon={Icon}
|
||||
disabled={disabled}
|
||||
focus={focus}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
applyShadow={false}
|
||||
applyBlur={false}
|
||||
size="medium"
|
||||
position="standalone"
|
||||
/>
|
||||
);
|
||||
};
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
|
||||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useId, useState, type FocusEvent, type FormEvent } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLink, IconPencil } from 'twenty-ui/display';
|
||||
|
||||
type EditLinkPopoverProps = {
|
||||
defaultValue: string | undefined;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const EditLinkPopover = ({
|
||||
defaultValue = '',
|
||||
editor,
|
||||
}: EditLinkPopoverProps) => {
|
||||
const instanceId = useId();
|
||||
const dropdownId = `edit-link-popover-${instanceId}`;
|
||||
const isActive = isNonEmptyString(defaultValue);
|
||||
const { t } = useLingui();
|
||||
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const { toggleDropdown } = useToggleDropdown();
|
||||
|
||||
const handleSubmit = (
|
||||
event: FormEvent<HTMLFormElement> | FocusEvent<HTMLInputElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isDefined(value)) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: value })
|
||||
.run();
|
||||
}
|
||||
|
||||
toggleDropdown({ dropdownComponentInstanceIdFromProps: dropdownId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
onOpen={() => {
|
||||
setValue(defaultValue);
|
||||
}}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextInput
|
||||
placeholder={t`Enter link`}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onBlur={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownId={dropdownId}
|
||||
isDropdownInModal={true}
|
||||
clickableComponent={
|
||||
<BubbleMenuIconButton
|
||||
isActive={isActive}
|
||||
Icon={isActive ? IconPencil : IconLink}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
|
||||
import { TurnIntoBlockDropdown } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown';
|
||||
import { useTextBubbleState } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState';
|
||||
import { isTextSelected } from '@/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected';
|
||||
import styled from '@emotion/styled';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export const StyledBubbleMenuContainer = styled.div`
|
||||
backdrop-filter: blur(20px);
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-shadow: ${({ theme }) =>
|
||||
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
type TextBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
|
||||
const state = useTextBubbleState(editor);
|
||||
const menuActions = [
|
||||
{
|
||||
Icon: IconBold,
|
||||
onClick: () => editor.chain().focus().toggleBold().run(),
|
||||
isActive: state.isBold,
|
||||
},
|
||||
{
|
||||
Icon: IconItalic,
|
||||
onClick: () => editor.chain().focus().toggleItalic().run(),
|
||||
isActive: state.isItalic,
|
||||
},
|
||||
{
|
||||
Icon: IconUnderline,
|
||||
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
||||
isActive: state.isUnderline,
|
||||
},
|
||||
{
|
||||
Icon: IconStrikethrough,
|
||||
onClick: () => editor.chain().focus().toggleStrike().run(),
|
||||
isActive: state.isStrike,
|
||||
},
|
||||
];
|
||||
|
||||
const handleShouldShow = () => {
|
||||
if (editor.isActive('image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isTextSelected({ editor });
|
||||
};
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="text-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
<TurnIntoBlockDropdown editor={editor} />
|
||||
{menuActions.map(({ Icon, onClick, isActive }) => {
|
||||
return (
|
||||
<BubbleMenuIconButton
|
||||
key={Icon.name || Icon.displayName || 'unknown'}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
|
||||
import { useTurnIntoBlockOptions } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type Editor } from '@tiptap/react';
|
||||
import { useId } from 'react';
|
||||
import { IconPilcrow } from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
const StyledMenuItem = styled.button`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: 4px;
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
padding: 0 ${({ theme }) => theme.spacing(1.5)};
|
||||
border-radius: ${({ theme }) => theme.spacing(1.5)};
|
||||
|
||||
:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.medium};
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type TurnIntoBlockDropdownProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const TurnIntoBlockDropdown = ({
|
||||
editor,
|
||||
}: TurnIntoBlockDropdownProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const instanceId = useId();
|
||||
const dropdownId = `turn-into-block-dropdown-${instanceId}`;
|
||||
const { toggleDropdown } = useToggleDropdown();
|
||||
|
||||
const options = useTurnIntoBlockOptions(editor);
|
||||
const activeItem = options.find((option) => option.isActive());
|
||||
const { icon: ActiveIcon = IconPilcrow, title: activeTitle = 'Paragraph' } =
|
||||
activeItem ?? {};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
{options.map(({ id, title, icon, onClick }) => (
|
||||
<MenuItem
|
||||
key={id}
|
||||
text={title}
|
||||
LeftIcon={icon}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
toggleDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownId={dropdownId}
|
||||
clickableComponent={
|
||||
<StyledMenuItem>
|
||||
<ActiveIcon size={theme.icon.size.md} />
|
||||
{activeTitle}
|
||||
</StyledMenuItem>
|
||||
}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing(1), 10),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { ResizableImageView } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView';
|
||||
import {
|
||||
type ImageOptions,
|
||||
Image as TiptapImage,
|
||||
} from '@tiptap/extension-image';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
export const ResizableImage = TiptapImage.extend<ImageOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
align: {
|
||||
default: 'left',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return ReactNodeViewRenderer(ResizableImageView);
|
||||
},
|
||||
});
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const IMAGE_MIN_WIDTH = 32;
|
||||
const IMAGE_MAX_WIDTH = 600;
|
||||
|
||||
const StyledNodeViewWrapper = styled(NodeViewWrapper)`
|
||||
height: 100%;
|
||||
${({ align }) => {
|
||||
switch (align) {
|
||||
case 'left':
|
||||
return css`
|
||||
margin-left: 0;
|
||||
`;
|
||||
case 'right':
|
||||
return css`
|
||||
margin-right: 0;
|
||||
`;
|
||||
case 'center':
|
||||
return css`
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const StyledImageWrapper = styled.div<{ width?: number }>`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledImageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImageHandle = styled.div<{ handle: 'left' | 'right' }>`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
background-color: ${({ theme }) => theme.background.primaryInverted};
|
||||
border: 1px solid ${({ theme }) => theme.background.primary};
|
||||
cursor: col-resize;
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: ${({ theme }) => theme.spacing(2)};
|
||||
z-index: 1;
|
||||
|
||||
${({ handle, theme }) => {
|
||||
if (handle === 'left') {
|
||||
return css`
|
||||
left: ${theme.spacing(1)};
|
||||
`;
|
||||
}
|
||||
|
||||
return css`
|
||||
right: ${theme.spacing(1)};
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
type ResizeParams = {
|
||||
initialWidth: number;
|
||||
initialClientX: number;
|
||||
handleUsed: 'left' | 'right';
|
||||
};
|
||||
|
||||
type ResizableImageViewProps = NodeViewProps;
|
||||
|
||||
export const ResizableImageView = (props: ResizableImageViewProps) => {
|
||||
const { editor, node, updateAttributes } = props;
|
||||
const { width: initialWidth, align = 'left', src, alt } = node.attrs;
|
||||
|
||||
const imageWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Controls visibility of resize handles when hovering over the image
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [width, setWidth] = useState(initialWidth || 0);
|
||||
// Controls actual resize operation state (null = not resizing, object = actively resizing)
|
||||
const [resizeParams, setResizeParams] = useState<ResizeParams | null>(null);
|
||||
|
||||
// Create stable event handlers using a closure approach
|
||||
const createMouseHandlers = useCallback(() => {
|
||||
let currentResizeParams: ResizeParams | null = null;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const imageWrapper = imageWrapperRef.current;
|
||||
|
||||
if (!isDefined(currentResizeParams) || !isDefined(imageWrapper)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - currentResizeParams.initialClientX;
|
||||
const { initialWidth, handleUsed } = currentResizeParams;
|
||||
|
||||
let newWidth =
|
||||
align === 'center'
|
||||
? initialWidth + (handleUsed === 'left' ? -deltaX * 2 : deltaX * 2)
|
||||
: initialWidth + (handleUsed === 'left' ? -deltaX : deltaX);
|
||||
|
||||
const maxWidth =
|
||||
editor.view.dom.firstElementChild?.clientWidth || IMAGE_MAX_WIDTH;
|
||||
newWidth = Math.min(Math.max(newWidth, IMAGE_MIN_WIDTH), maxWidth);
|
||||
|
||||
setWidth(newWidth);
|
||||
imageWrapper.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
const imageWrapper = imageWrapperRef.current;
|
||||
|
||||
if (!isDefined(imageWrapper) || !isDefined(currentResizeParams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!event.target || !imageWrapper.contains(event.target as Node)) &&
|
||||
isHovering
|
||||
) {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalWidth = imageWrapper.clientWidth;
|
||||
currentResizeParams = null;
|
||||
setResizeParams(null);
|
||||
updateAttributes({ width: finalWidth });
|
||||
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const startResize = (resizeParams: ResizeParams) => {
|
||||
currentResizeParams = resizeParams;
|
||||
setResizeParams(resizeParams);
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return { startResize };
|
||||
}, [editor, align, isHovering, updateAttributes]);
|
||||
|
||||
const { startResize } = createMouseHandlers();
|
||||
|
||||
const handleImageHandleMouseDown = useCallback(
|
||||
(handle: 'left' | 'right', event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const resizeParams = {
|
||||
initialWidth: imageWrapperRef.current?.clientWidth ?? IMAGE_MAX_WIDTH,
|
||||
initialClientX: event.clientX,
|
||||
handleUsed: handle,
|
||||
};
|
||||
|
||||
startResize(resizeParams);
|
||||
},
|
||||
[startResize],
|
||||
);
|
||||
|
||||
const handleImageHover = useCallback(() => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsHovering(true);
|
||||
}, [editor.isEditable]);
|
||||
|
||||
const handleImageHoverEnd = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledNodeViewWrapper
|
||||
onMouseEnter={handleImageHover}
|
||||
onMouseLeave={handleImageHoverEnd}
|
||||
align={align}
|
||||
>
|
||||
<StyledImageWrapper
|
||||
ref={imageWrapperRef}
|
||||
style={{ width: width ? `${width}px` : 'fit-content' }}
|
||||
>
|
||||
<StyledImageContainer>
|
||||
<StyledImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
contentEditable={false}
|
||||
/>
|
||||
{/* Show resize handles when hovering over image OR actively resizing */}
|
||||
{(isHovering || isDefined(resizeParams)) && (
|
||||
<>
|
||||
<StyledImageHandle
|
||||
handle="left"
|
||||
onMouseDown={(e) => handleImageHandleMouseDown('left', e)}
|
||||
/>
|
||||
<StyledImageHandle
|
||||
handle="right"
|
||||
onMouseDown={(e) => handleImageHandleMouseDown('right', e)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledImageContainer>
|
||||
</StyledImageWrapper>
|
||||
</StyledNodeViewWrapper>
|
||||
);
|
||||
};
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
UploadImagePlugin,
|
||||
type UploadImagePluginProps,
|
||||
} from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin';
|
||||
import { Extension } from '@tiptap/core';
|
||||
|
||||
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'> & {};
|
||||
|
||||
type UploadImageStorage = {
|
||||
placeholderImages: Set<string>;
|
||||
};
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Storage {
|
||||
uploadImage: UploadImageStorage;
|
||||
}
|
||||
}
|
||||
|
||||
export const UploadImageExtension = Extension.create<
|
||||
UploadImageOptions,
|
||||
UploadImageStorage
|
||||
>({
|
||||
name: 'uploadImage',
|
||||
|
||||
addOptions: () => {
|
||||
return {
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
],
|
||||
onImageUpload: undefined,
|
||||
onImageUploadError: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage: () => {
|
||||
return {
|
||||
placeholderImages: new Set(),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { onImageUpload } = this.options;
|
||||
|
||||
if (!onImageUpload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
UploadImagePlugin({
|
||||
editor: this.editor,
|
||||
allowedMimeTypes: this.options.allowedMimeTypes,
|
||||
onImageUpload: this.options.onImageUpload,
|
||||
onImageUploadError: this.options.onImageUploadError,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { type Node } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { type EditorView } from '@tiptap/pm/view';
|
||||
|
||||
export type UploadImagePluginProps = {
|
||||
editor: Editor;
|
||||
allowedMimeTypes?: string[];
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onImageUploadError?: (error: Error, file: File) => void;
|
||||
};
|
||||
|
||||
export const UploadImagePlugin = (options: UploadImagePluginProps) => {
|
||||
const { editor, onImageUpload, allowedMimeTypes, onImageUploadError } =
|
||||
options;
|
||||
|
||||
const handleImageUpload = (view: EditorView, file: File, pos?: number) => {
|
||||
const placeholderSrc = URL.createObjectURL(file);
|
||||
|
||||
const { tr, schema } = view.state;
|
||||
const imageNode = schema.nodes.image.create({
|
||||
src: placeholderSrc,
|
||||
alt: file.name,
|
||||
});
|
||||
|
||||
editor.extensionStorage.uploadImage.placeholderImages.add(placeholderSrc);
|
||||
|
||||
const resolvedPos =
|
||||
pos !== undefined
|
||||
? view.state.doc.resolve(pos)
|
||||
: view.state.selection.$head;
|
||||
|
||||
const transaction = tr.insert(resolvedPos.pos, imageNode);
|
||||
view.dispatch(transaction);
|
||||
|
||||
onImageUpload?.(file)
|
||||
.then((uploadedSrc) => {
|
||||
const updateTr = view.state.tr;
|
||||
|
||||
const predicate = (node: Node) =>
|
||||
node.type.name === 'image' && node.attrs.src === placeholderSrc;
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (predicate(node)) {
|
||||
updateTr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
src: uploadedSrc,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
view.dispatch(updateTr);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
const removeTr = view.state.tr;
|
||||
const predicate = (node: Node) =>
|
||||
node.type.name === 'image' && node.attrs.src === placeholderSrc;
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (predicate(node)) {
|
||||
removeTr.delete(pos, pos + node.nodeSize);
|
||||
return false; // Stop traversal after finding the target
|
||||
}
|
||||
});
|
||||
|
||||
view.dispatch(removeTr);
|
||||
|
||||
onImageUploadError?.(error, file);
|
||||
})
|
||||
.finally(() => {
|
||||
editor.extensionStorage.uploadImage.placeholderImages.delete(
|
||||
placeholderSrc,
|
||||
);
|
||||
URL.revokeObjectURL(placeholderSrc);
|
||||
});
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey('uploadImage'),
|
||||
props: {
|
||||
handleDrop: (view, event) => {
|
||||
if (!onImageUpload || !event.dataTransfer?.files?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const images = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
allowedMimeTypes?.includes(file.type),
|
||||
);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
if (!pos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
images.forEach((file) => handleImageUpload(view, file, pos.pos));
|
||||
return true;
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
if (!onImageUpload || !event.clipboardData?.files?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const images = Array.from(event.clipboardData.files).filter((file) =>
|
||||
allowedMimeTypes?.includes(file.type),
|
||||
);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
images.forEach((file) => handleImageUpload(view, file));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import { ResizableImage } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage';
|
||||
import { UploadImageExtension } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension';
|
||||
import { getInitialEmailEditorContent } from '@/workflow/workflow-variables/utils/getInitialEmailEditorContent';
|
||||
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
|
||||
import { Bold } from '@tiptap/extension-bold';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { HardBreak } from '@tiptap/extension-hard-break';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import { Italic } from '@tiptap/extension-italic';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Strike } from '@tiptap/extension-strike';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Dropcursor, Placeholder, UndoRedo } from '@tiptap/extensions';
|
||||
import { type Editor, useEditor } from '@tiptap/react';
|
||||
import { type DependencyList, useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseEmailEditorProps = {
|
||||
placeholder: string | undefined;
|
||||
readonly: boolean | undefined;
|
||||
defaultValue: string | undefined | null;
|
||||
onUpdate: (editor: Editor) => void;
|
||||
onFocus?: (editor: Editor) => void;
|
||||
onBlur?: (editor: Editor) => void;
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onImageUploadError?: (error: Error, file: File) => void;
|
||||
};
|
||||
|
||||
export const useEmailEditor = (
|
||||
{
|
||||
placeholder,
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImageUpload,
|
||||
onImageUploadError,
|
||||
}: UseEmailEditorProps,
|
||||
dependencies?: DependencyList,
|
||||
) => {
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
VariableTag,
|
||||
HardBreak.configure({
|
||||
keepMarks: false,
|
||||
}),
|
||||
UndoRedo,
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Underline,
|
||||
Heading.configure({
|
||||
levels: [1, 2, 3],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
ResizableImage,
|
||||
Dropcursor,
|
||||
UploadImageExtension.configure({
|
||||
onImageUpload,
|
||||
onImageUploadError,
|
||||
}),
|
||||
],
|
||||
[placeholder, onImageUpload, onImageUploadError],
|
||||
);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: isDefined(defaultValue)
|
||||
? getInitialEmailEditorContent(defaultValue)
|
||||
: undefined,
|
||||
editable: !readonly,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate(editor);
|
||||
},
|
||||
onFocus: ({ editor }) => {
|
||||
onFocus?.(editor);
|
||||
},
|
||||
onBlur: ({ editor }) => {
|
||||
onBlur?.(editor);
|
||||
},
|
||||
editorProps: {
|
||||
scrollThreshold: 60,
|
||||
scrollMargin: 60,
|
||||
},
|
||||
injectCSS: false,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
|
||||
return editor;
|
||||
};
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
|
||||
export const useTextBubbleState = (editor: Editor) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
isBold: ctx.editor.isActive('bold'),
|
||||
isItalic: ctx.editor.isActive('italic'),
|
||||
isStrike: ctx.editor.isActive('strike'),
|
||||
isUnderline: ctx.editor.isActive('underline'),
|
||||
isLink: ctx.editor.isActive('link'),
|
||||
linkHref: ctx.editor.getAttributes('link').href || '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
import { type Editor, useEditorState } from '@tiptap/react';
|
||||
import {
|
||||
type IconComponent,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconPilcrow,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export type TurnIntoBlockOptions = {
|
||||
title: string;
|
||||
id: string;
|
||||
disabled: () => boolean;
|
||||
isActive: () => boolean;
|
||||
onClick: () => void;
|
||||
icon: IconComponent;
|
||||
};
|
||||
|
||||
export const useTurnIntoBlockOptions = (editor: Editor) => {
|
||||
return useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }): TurnIntoBlockOptions[] => [
|
||||
{
|
||||
id: 'paragraph',
|
||||
title: 'Paragraph',
|
||||
icon: IconPilcrow,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setParagraph().run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setParagraph();
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('paragraph');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading1',
|
||||
title: 'Heading 1',
|
||||
icon: IconH1,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 1 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 1 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 1 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading2',
|
||||
title: 'Heading 2',
|
||||
icon: IconH2,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 2 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 2 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 2 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading3',
|
||||
title: 'Heading 3',
|
||||
icon: IconH3,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 3 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 3 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 3 });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { type Editor, isTextSelection } from '@tiptap/core';
|
||||
|
||||
type IsTextSelectedProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const isTextSelected = ({ editor }: IsTextSelectedProps) => {
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to },
|
||||
},
|
||||
} = editor;
|
||||
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock =
|
||||
!doc.textBetween(from, to).length && isTextSelection(selection);
|
||||
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
Reference in New Issue
Block a user