Feat/advanced text editor capability presets (#23657)

# Email editor for Compose campaigns

## Short version

Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.

**Product**

- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker

**Technical**

- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria

**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.


---

## Detailed version

### Product requirements

**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.

**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.

#### What a user can now do

| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |

#### Deliberate product decisions

- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.

### Technical strategy

#### 1. Capability presets (the foundation)

Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.

Now:

- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do

The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.

#### 2. Schema / renderer split

The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.

```
twenty-front     TipTap extensions + node views + shared email settings UI
twenty-shared    versioned email-document schema + structural traversal
twenty-emails    react-email renderers (imported by twenty-server)
twenty-server    surface-specific variable resolution, validation, send
```

Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.

#### 3. Section typography cascade

The subtle part, and the one that would have silently shipped broken.

Section typography *looks* like it should cascade via CSS. It does not:

```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```

Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.

So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.

Verified against real rendered output:

| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |

#### 4. Storage

`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.

Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.

#### 5. Image hosting

Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.

This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.

Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.

### Bugs fixed along the way

- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.

### Review notes / known limitations

**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.

**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.

**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.

**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.

**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.

**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Marie
2026-08-05 12:38:06 +02:00
committed by GitHub
parent 5effee7754
commit 1d755983ff
243 changed files with 7620 additions and 569 deletions
@@ -1,15 +1,24 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type Editor } from '@tiptap/core';
import { useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useCampaignBodyState } from '@/activities/emails/hooks/useCampaignBodyState';
import { useCampaignEmailEditorVariables } from '@/activities/emails/hooks/useCampaignEmailEditorVariables';
import { InsertRail } from '@/advanced-text-editor/components/InsertRail';
import { useUploadEmailImage } from '@/advanced-text-editor/hooks/useUploadEmailImage';
import { activeEmailEditorState } from '@/advanced-text-editor/states/activeEmailEditorState';
import { type MessageCampaign } from '@/activities/emails/types/MessageCampaign';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
position: relative;
`;
type CampaignBodyFieldProps = {
@@ -18,6 +27,19 @@ type CampaignBodyFieldProps = {
export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
const { body, setBody, flush } = useCampaignBodyState({ campaign });
const setActiveEmailEditor = useSetAtomState(activeEmailEditorState);
const { uploadEmailImage } = useUploadEmailImage();
const { variables } = useCampaignEmailEditorVariables();
const [bodyEditor, setBodyEditor] = useState<Editor | null>(null);
const handleEditorReady = useCallback(
(editor: Editor | null) => {
setActiveEmailEditor(editor);
setBodyEditor(editor);
},
[setActiveEmailEditor],
);
return (
<StyledContainer onBlur={() => flush()}>
@@ -26,7 +48,16 @@ export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
onChange={setBody}
placeholder={t`Type something or press "/" to see commands`}
preset="campaignBody"
onEditorReady={handleEditorReady}
onImageUpload={uploadEmailImage}
/>
{isDefined(bodyEditor) && (
<InsertRail
editor={bodyEditor}
onImageUpload={uploadEmailImage}
variables={variables}
/>
)}
</StyledContainer>
);
};
@@ -108,6 +108,12 @@ export const CampaignDetailsFields = ({
return (
<StyledFieldsContainer onBlur={() => detailsState.flush()}>
<FormTextFieldInput
label={t`Subject`}
defaultValue={detailsState.subject}
onChange={detailsState.setSubject}
placeholder={t`Subject`}
/>
<Select
dropdownId="campaign-composer-from-account"
label={t`From`}
@@ -145,12 +151,6 @@ export const CampaignDetailsFields = ({
</StyledHint>
</>
)}
<FormTextFieldInput
label={t`Subject`}
defaultValue={detailsState.subject}
onChange={detailsState.setSubject}
placeholder={t`Subject`}
/>
</StyledFieldsContainer>
);
};
@@ -0,0 +1,22 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useLingui } from '@lingui/react/macro';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { listCampaignVariablesForFields } from 'twenty-shared/utils';
export const useCampaignEmailEditorVariables = () => {
const { t } = useLingui();
const { objectMetadataItem: personObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const variables = [
...listCampaignVariablesForFields(personObjectMetadataItem.fields).map(
({ label, name }) => ({ label, value: `{{${name}}}` }),
),
{ label: t`Full name`, value: '{{fullName}}' },
{ label: t`Person ID`, value: '{{personId}}' },
];
return { variables };
};
@@ -1,9 +1,12 @@
import { ImageBubbleMenu } from '@/advanced-text-editor/components/ImageBubbleMenu';
import { LinkBubbleMenu } from '@/advanced-text-editor/components/LinkBubbleMenu';
import { TextBubbleMenu } from '@/advanced-text-editor/components/TextBubbleMenu';
import { type AdvancedTextEditorChrome } from '@/advanced-text-editor/types/AdvancedTextEditorPreset';
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
import { styled } from '@linaria/react';
import { EditorContent, type Editor } from '@tiptap/react';
import { EditorContent, type Editor, useEditorState } from '@tiptap/react';
import { isDefined, resolveCanvasTheme } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledEditorContainer = styled.div<{
@@ -72,6 +75,54 @@ const StyledEditorContainer = styled.div<{
line-height: 1.5;
margin-bottom: ${themeCssVariables.spacing[2]};
}
.block-section {
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
margin-bottom: ${themeCssVariables.spacing[2]};
outline: 1px dashed transparent;
outline-offset: 2px;
&:hover {
outline-color: ${themeCssVariables.border.color.medium};
}
}
.block-columns {
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[2]};
margin-bottom: ${themeCssVariables.spacing[2]};
}
.block-column {
box-sizing: border-box;
flex: 1;
min-width: 0;
outline: 1px dashed ${themeCssVariables.border.color.light};
outline-offset: 2px;
border-radius: ${themeCssVariables.border.radius.sm};
}
.block-button-wrapper {
margin-bottom: ${themeCssVariables.spacing[2]};
}
.block-button {
box-sizing: border-box;
cursor: text;
width: fit-content;
}
.block-divider {
border-left: none;
border-right: none;
border-bottom: none;
}
.ProseMirror-selectednode {
outline: 2px solid ${themeCssVariables.color.blue};
}
}
.ProseMirror-focused {
@@ -83,23 +134,103 @@ const StyledEditorContainer = styled.div<{
}
`;
const StyledCanvasBackdrop = styled.div`
box-sizing: border-box;
flex-grow: 1;
min-height: 100%;
padding: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[4]};
width: 100%;
`;
const StyledCanvasPage = styled.div`
box-sizing: border-box;
margin: 0 auto;
max-width: 100%;
min-height: 400px;
.editor-content {
min-height: inherit;
}
.tiptap {
color: inherit;
min-height: inherit;
padding: 0;
}
`;
type AdvancedTextEditorProps = {
readonly: boolean | undefined;
editor: Editor;
minHeight: number;
chrome?: AdvancedTextEditorChrome;
};
const TEXT_BUBBLE_MENU_EXTENSION_NAMES = [
'bold',
'italic',
'underline',
'strike',
'bulletList',
'orderedList',
'heading',
'link',
];
export const AdvancedTextEditor = ({
readonly,
editor,
minHeight,
chrome,
}: AdvancedTextEditorProps) => {
const hasTextBubbleMenu = TEXT_BUBBLE_MENU_EXTENSION_NAMES.some(
(extensionName) => hasEditorExtension(editor, extensionName),
);
const canvasTheme = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
resolveCanvasTheme(currentEditor.state.doc.attrs.canvasTheme),
});
const hasCanvasChrome = chrome === 'canvas' && isDefined(canvasTheme);
return (
<StyledEditorContainer readonly={readonly} minHeight={minHeight}>
<EditorContent className="editor-content" editor={editor} />
<ImageBubbleMenu editor={editor} />
<TextBubbleMenu editor={editor} />
<LinkBubbleMenu editor={editor} />
{hasCanvasChrome ? (
<StyledCanvasBackdrop
style={{
backgroundColor: canvasTheme.pageBackground,
padding: canvasTheme.pagePadding,
}}
>
<StyledCanvasPage
style={{
backgroundColor: canvasTheme.bodyBackground || undefined,
border:
canvasTheme.borderWidth !== '' &&
canvasTheme.borderWidth !== '0px'
? `${canvasTheme.borderWidth} solid ${canvasTheme.borderColor}`
: undefined,
borderRadius: canvasTheme.cornerRadius,
color: canvasTheme.textColor,
padding: canvasTheme.padding,
textAlign: canvasTheme.textAlign,
width: canvasTheme.width,
}}
>
<EditorContent className="editor-content" editor={editor} />
</StyledCanvasPage>
</StyledCanvasBackdrop>
) : (
<EditorContent className="editor-content" editor={editor} />
)}
{hasEditorExtension(editor, 'image') &&
!hasEditorExtension(editor, 'section') && (
<ImageBubbleMenu editor={editor} />
)}
{hasTextBubbleMenu && <TextBubbleMenu editor={editor} />}
{hasEditorExtension(editor, 'link') && <LinkBubbleMenu editor={editor} />}
</StyledEditorContainer>
);
};
@@ -1,46 +1,30 @@
import { styled } from '@linaria/react';
import React from 'react';
import type { IconComponent } from 'twenty-ui/icon';
import { FloatingIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { LightIconButton } from 'twenty-ui/input';
type BubbleMenuIconButtonProps = {
className?: string;
Icon?: IconComponent;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
isActive?: boolean;
};
const StyledBubbleMenuIconButtonContainer = styled.div`
border: none;
border-radius: ${themeCssVariables.spacing[1.5]};
height: ${themeCssVariables.spacing[6]};
width: ${themeCssVariables.spacing[6]};
`;
export const BubbleMenuIconButton = ({
className,
Icon,
disabled = false,
focus = false,
onClick,
isActive,
}: BubbleMenuIconButtonProps) => {
return (
<StyledBubbleMenuIconButtonContainer className={className}>
<FloatingIconButton
Icon={Icon}
disabled={disabled}
focus={focus}
onClick={onClick}
isActive={isActive}
applyShadow={false}
applyBlur={false}
size="medium"
position="standalone"
/>
</StyledBubbleMenuIconButtonContainer>
<LightIconButton
className={className}
Icon={Icon}
disabled={disabled}
onClick={onClick}
accent={isActive === true ? 'secondary' : 'tertiary'}
size="small"
/>
);
};
@@ -1,5 +1,5 @@
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/TextBubbleMenu';
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/StyledBubbleMenuContainer';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
@@ -0,0 +1,373 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type Editor } from '@tiptap/core';
import { useRef, useState } from 'react';
import { EMAIL_IMAGE_MIME_TYPES } from 'twenty-shared/constants';
import { isDefined, TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
import {
IconBox,
IconClick,
IconCode,
IconColumns,
IconH1,
IconH2,
IconH3,
IconLayoutGrid,
IconList,
IconListNumbers,
IconMinus,
IconPhoto,
IconTypography,
IconVariable,
} from 'twenty-ui/icon';
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
import { Button, LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledRail = styled.div`
align-items: center;
backdrop-filter: blur(20px);
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.pill};
box-shadow:
0px 2px 4px 0px ${themeCssVariables.background.transparent.light},
0px 0px 4px 0px ${themeCssVariables.background.transparent.medium};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[1]};
`;
const StyledRailContainer = styled.div`
left: ${themeCssVariables.spacing[3]};
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 1;
`;
const StyledPopover = styled.div`
background-color: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-shadow:
0px 2px 4px 0px ${themeCssVariables.background.transparent.light},
0px 0px 4px 0px ${themeCssVariables.background.transparent.medium};
left: calc(100% + ${themeCssVariables.spacing[2]});
max-height: 320px;
min-width: 180px;
overflow-y: auto;
padding: ${themeCssVariables.spacing[1]};
position: absolute;
top: 0;
`;
const StyledImageForm = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[1]};
width: 220px;
`;
const StyledVariableLiteral = styled.span`
font-family: ${themeCssVariables.code.font.family};
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledImageHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xs};
`;
const columnJson = () => ({
type: TIPTAP_NODE_TYPES.COLUMN,
content: [{ type: TIPTAP_NODE_TYPES.PARAGRAPH }],
});
type InsertRailProps = {
editor: Editor;
onImageUpload?: (file: File) => Promise<UploadedImage>;
variables?: Array<{ label: string; value: string }>;
};
export const InsertRail = ({
editor,
onImageUpload,
variables = [],
}: InsertRailProps) => {
const { t } = useLingui();
const imageFileInputRef = useRef<HTMLInputElement>(null);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [openMenu, setOpenMenu] = useState<
'text' | 'image' | 'blocks' | 'variables' | null
>(null);
const [imageUrl, setImageUrl] = useState('');
const hasVariables =
variables.length > 0 && hasEditorExtension(editor, 'variableTag');
const insertVariable = (value: string) => {
editor.chain().focus().insertVariableTag(value).run();
setOpenMenu(null);
};
const insertAtEnd = (content: object) => {
editor
.chain()
.insertContentAt(editor.state.doc.content.size, content)
.focus('end')
.scrollIntoView()
.run();
setOpenMenu(null);
};
const listItemJson = () => ({
type: TIPTAP_NODE_TYPES.LIST_ITEM,
content: [{ type: TIPTAP_NODE_TYPES.PARAGRAPH }],
});
const textItems = [
{
Icon: IconTypography,
label: t`Text`,
content: { type: TIPTAP_NODE_TYPES.PARAGRAPH },
},
{
Icon: IconH1,
label: t`Title`,
content: { type: TIPTAP_NODE_TYPES.HEADING, attrs: { level: 1 } },
},
{
Icon: IconH2,
label: t`Subtitle`,
content: { type: TIPTAP_NODE_TYPES.HEADING, attrs: { level: 2 } },
},
{
Icon: IconH3,
label: t`Heading`,
content: { type: TIPTAP_NODE_TYPES.HEADING, attrs: { level: 3 } },
},
{
Icon: IconList,
label: t`Bullet list`,
content: {
type: TIPTAP_NODE_TYPES.BULLET_LIST,
content: [listItemJson()],
},
},
{
Icon: IconListNumbers,
label: t`Numbered list`,
content: {
type: TIPTAP_NODE_TYPES.ORDERED_LIST,
content: [listItemJson()],
},
},
];
const handleFilePicked = async (file: File | undefined) => {
if (!isDefined(file) || !isDefined(onImageUpload)) {
return;
}
setIsUploadingImage(true);
try {
const uploadedImage = await onImageUpload(file);
insertAtEnd({
type: TIPTAP_NODE_TYPES.IMAGE,
attrs: {
fileId: uploadedImage.fileId ?? null,
src: uploadedImage.url,
},
});
} catch {
} finally {
setIsUploadingImage(false);
}
};
const handleInsertImage = () => {
if (imageUrl.trim() === '') {
return;
}
insertAtEnd({
type: TIPTAP_NODE_TYPES.IMAGE,
attrs: { src: imageUrl.trim() },
});
setImageUrl('');
};
const blockItems = [
{
Icon: IconBox,
label: t`Section`,
content: {
type: TIPTAP_NODE_TYPES.SECTION,
content: [{ type: TIPTAP_NODE_TYPES.PARAGRAPH }],
},
},
{
Icon: IconColumns,
label: t`2 Columns`,
content: {
type: TIPTAP_NODE_TYPES.COLUMNS,
content: [columnJson(), columnJson()],
},
},
{
Icon: IconColumns,
label: t`3 Columns`,
content: {
type: TIPTAP_NODE_TYPES.COLUMNS,
content: [columnJson(), columnJson(), columnJson()],
},
},
{
Icon: IconClick,
label: t`Button`,
content: {
type: TIPTAP_NODE_TYPES.BUTTON,
content: [{ type: TIPTAP_NODE_TYPES.TEXT, text: t`Click here` }],
},
},
{
Icon: IconMinus,
label: t`Divider`,
content: { type: TIPTAP_NODE_TYPES.DIVIDER },
},
{
Icon: IconCode,
label: t`HTML`,
content: { type: TIPTAP_NODE_TYPES.HTML },
},
];
return (
<StyledRailContainer>
<input
ref={imageFileInputRef}
type="file"
accept={EMAIL_IMAGE_MIME_TYPES.join(',')}
hidden
onChange={(event) => {
void handleFilePicked(event.target.files?.[0]);
event.target.value = '';
}}
/>
<StyledRail>
<LightIconButton
Icon={IconTypography}
size="medium"
accent={openMenu === 'text' ? 'secondary' : 'tertiary'}
title={t`Text`}
onClick={() => setOpenMenu(openMenu === 'text' ? null : 'text')}
/>
<LightIconButton
Icon={IconPhoto}
size="medium"
accent={openMenu === 'image' ? 'secondary' : 'tertiary'}
title={isUploadingImage ? t`Uploading...` : t`Image`}
disabled={isUploadingImage}
onClick={() => {
if (isDefined(onImageUpload)) {
imageFileInputRef.current?.click();
return;
}
setOpenMenu(openMenu === 'image' ? null : 'image');
}}
/>
<LightIconButton
Icon={IconLayoutGrid}
size="medium"
accent={openMenu === 'blocks' ? 'secondary' : 'tertiary'}
title={t`Blocks`}
onClick={() => setOpenMenu(openMenu === 'blocks' ? null : 'blocks')}
/>
{hasVariables && (
<LightIconButton
Icon={IconVariable}
size="medium"
accent={openMenu === 'variables' ? 'secondary' : 'tertiary'}
title={t`Variables`}
onClick={() =>
setOpenMenu(openMenu === 'variables' ? null : 'variables')
}
/>
)}
</StyledRail>
{openMenu === 'variables' && (
<StyledPopover>
{variables.map(({ label, value }) => (
<MenuItem
key={value}
text={<StyledVariableLiteral>{value}</StyledVariableLiteral>}
contextualText={label}
onClick={() => insertVariable(value)}
/>
))}
</StyledPopover>
)}
{openMenu === 'text' && (
<StyledPopover>
{textItems.map(({ Icon, label, content }) => (
<MenuItem
key={label}
LeftIcon={Icon}
text={label}
onClick={() => insertAtEnd(content)}
/>
))}
</StyledPopover>
)}
{openMenu === 'blocks' && (
<StyledPopover>
{blockItems.map(({ Icon, label, content }) => (
<MenuItem
key={label}
LeftIcon={Icon}
text={label}
onClick={() => insertAtEnd(content)}
/>
))}
</StyledPopover>
)}
{openMenu === 'image' && (
<StyledPopover>
<StyledImageForm>
<TextInput
value={imageUrl}
onChange={setImageUrl}
placeholder={t`Image URL`}
fullWidth
autoFocus
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleInsertImage();
}
}}
/>
<StyledImageHint>
{t`Paste a link to a hosted image`}
</StyledImageHint>
<Button
title={t`Insert image`}
size="small"
onClick={handleInsertImage}
/>
</StyledImageForm>
</StyledPopover>
)}
</StyledRailContainer>
);
};
@@ -1,6 +1,6 @@
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover';
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/TextBubbleMenu';
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/StyledBubbleMenuContainer';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
@@ -0,0 +1,14 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledBubbleMenuContainer = styled.div`
backdrop-filter: blur(20px);
background-color: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.md};
box-shadow:
0px 2px 4px 0px ${themeCssVariables.background.transparent.light},
0px 0px 4px 0px ${themeCssVariables.background.transparent.medium};
display: inline-flex;
gap: 2px;
padding: 2px;
`;
@@ -1,10 +1,12 @@
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/StyledBubbleMenuContainer';
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover';
import { TurnIntoBlockDropdown } from '@/advanced-text-editor/components/TurnIntoBlockDropdown';
import { useTextBubbleState } from '@/advanced-text-editor/hooks/useTextBubbleState';
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
import { isTextSelected } from '@/advanced-text-editor/utils/isTextSelected';
import { styled } from '@linaria/react';
import { type Editor } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
import { BubbleMenu } from '@tiptap/react/menus';
import {
IconBold,
@@ -14,19 +16,6 @@ import {
IconStrikethrough,
IconUnderline,
} from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledBubbleMenuContainer = styled.div`
backdrop-filter: blur(20px);
background-color: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.md};
box-shadow:
0px 2px 4px 0px ${themeCssVariables.background.transparent.light},
0px 0px 4px 0px ${themeCssVariables.background.transparent.medium};
display: inline-flex;
gap: 2px;
padding: 2px;
`;
type TextBubbleMenuProps = {
editor: Editor;
@@ -37,41 +26,52 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
const menuActions = [
{
Icon: IconBold,
extensionName: 'bold',
onClick: () => editor.chain().focus().toggleBold().run(),
isActive: state.isBold,
},
{
Icon: IconItalic,
extensionName: 'italic',
onClick: () => editor.chain().focus().toggleItalic().run(),
isActive: state.isItalic,
},
{
Icon: IconUnderline,
extensionName: 'underline',
onClick: () => editor.chain().focus().toggleUnderline().run(),
isActive: state.isUnderline,
},
{
Icon: IconStrikethrough,
extensionName: 'strike',
onClick: () => editor.chain().focus().toggleStrike().run(),
isActive: state.isStrike,
},
{
Icon: IconList,
extensionName: 'bulletList',
onClick: () => editor.chain().focus().wrapInList('bulletList').run(),
isActive: state.isBulletList,
},
{
Icon: IconListNumbers,
extensionName: 'orderedList',
onClick: () => editor.chain().focus().wrapInList('orderedList').run(),
isActive: state.isOrderedList,
},
];
].filter(({ extensionName }) => hasEditorExtension(editor, extensionName));
const handleShouldShow = () => {
if (editor.isActive('image')) {
return false;
}
const { selection } = editor.state;
if (selection instanceof NodeSelection && selection.node.isAtom) {
return false;
}
return isTextSelected({ editor });
};
@@ -83,7 +83,9 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
updateDelay={0}
>
<StyledBubbleMenuContainer>
<TurnIntoBlockDropdown editor={editor} />
{hasEditorExtension(editor, 'heading') && (
<TurnIntoBlockDropdown editor={editor} />
)}
{menuActions.map(({ Icon, onClick, isActive }) => {
return (
<BubbleMenuIconButton
@@ -94,7 +96,9 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
/>
);
})}
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
{hasEditorExtension(editor, 'link') && (
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
)}
</StyledBubbleMenuContainer>
</BubbleMenu>
);
@@ -1,4 +1,5 @@
import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor';
import { type AdvancedTextEditorPresetName } from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor } from 'storybook/test';
@@ -17,16 +18,17 @@ const EditorWrapper = ({
defaultValue = null,
onUpdate = fn(),
minHeight = 200,
enableSlashCommand = true,
preset = 'recordRichTextField',
}: {
readonly?: boolean;
placeholder?: string;
defaultValue?: string | null;
onUpdate?: (content: string) => void;
minHeight?: number;
enableSlashCommand?: boolean;
preset?: AdvancedTextEditorPresetName;
}) => {
const editor = useAdvancedTextEditor({
preset,
placeholder,
readonly,
defaultValue,
@@ -36,12 +38,13 @@ const EditorWrapper = ({
},
onImageUpload: async (file: File) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`;
return {
url: `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`,
};
},
onImageUploadError: (_error: Error, _file: File) => {
// Handle image upload error
},
enableSlashCommand,
});
if (!editor) {
@@ -278,6 +281,13 @@ export const Empty: Story = {
},
};
export const AiChatPreset: Story = {
args: {
preset: 'aiChat',
placeholder: 'Ask, search or make anything...',
},
};
export const Interactive: Story = {
args: {
onUpdate: fn(),
@@ -0,0 +1,69 @@
import { CampaignVariableTag } from '@/advanced-text-editor/extensions/campaign-variables/CampaignVariableTag';
import { ButtonNode } from '@/advanced-text-editor/extensions/blocks/ButtonNode';
import { HtmlNode } from '@/advanced-text-editor/extensions/blocks/HtmlNode';
import { ColumnNode } from '@/advanced-text-editor/extensions/blocks/ColumnNode';
import { ColumnsNode } from '@/advanced-text-editor/extensions/blocks/ColumnsNode';
import { DividerNode } from '@/advanced-text-editor/extensions/blocks/DividerNode';
import { SectionNode } from '@/advanced-text-editor/extensions/blocks/SectionNode';
import { ResizableImage } from '@/advanced-text-editor/extensions/resizable-image/ResizableImage';
import { UploadImageExtension } from '@/advanced-text-editor/extensions/resizable-image/UploadImageExtension';
import { SlashCommand } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { type AdvancedTextEditorCapability } from '@/advanced-text-editor/types/AdvancedTextEditorCapability';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import { type AnyExtension } from '@tiptap/core';
import { Bold } from '@tiptap/extension-bold';
import { Heading } from '@tiptap/extension-heading';
import { Italic } from '@tiptap/extension-italic';
import { Link } from '@tiptap/extension-link';
import { ListKit } from '@tiptap/extension-list';
import { Strike } from '@tiptap/extension-strike';
import { Underline } from '@tiptap/extension-underline';
export type AdvancedTextEditorExtensionContext = {
onImageUpload?: (file: File) => Promise<UploadedImage>;
onImageUploadError?: (error: Error, file: File) => void;
};
type AdvancedTextEditorExtensionFactory = (
context: AdvancedTextEditorExtensionContext,
) => AnyExtension[];
export const ADVANCED_TEXT_EDITOR_CAPABILITY_EXTENSIONS: Record<
AdvancedTextEditorCapability,
AdvancedTextEditorExtensionFactory
> = {
basicMarks: () => [Bold, Italic, Strike, Underline],
headings: () => [
Heading.configure({
levels: [1, 2, 3],
}),
],
lists: () => [ListKit],
links: () => [
Link.configure({
openOnClick: false,
}),
],
images: ({ onImageUpload, onImageUploadError }) => [
ResizableImage,
UploadImageExtension.configure({
onImageUpload,
onImageUploadError,
}),
],
variables: () => [VariableTag],
campaignVariables: () => [CampaignVariableTag],
mentions: () => [MentionTag, MentionSuggestion],
slashCommand: () => [SlashCommand],
blocks: () => [
SectionNode,
ColumnsNode,
ColumnNode,
ButtonNode,
DividerNode,
HtmlNode,
],
};
@@ -0,0 +1,27 @@
import { t } from '@lingui/core/macro';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Dropcursor } from '@tiptap/extensions/drop-cursor';
import { Placeholder } from '@tiptap/extensions/placeholder';
import { UndoRedo } from '@tiptap/extensions/undo-redo';
import { type AnyExtension } from '@tiptap/core';
export const buildAdvancedTextEditorCoreExtensions = ({
placeholder,
}: {
placeholder: string | undefined;
}): AnyExtension[] => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: placeholder ?? t`Enter text or Type '/' for commands`,
}),
HardBreak.configure({
keepMarks: false,
}),
UndoRedo,
Dropcursor,
];
@@ -1,3 +1,5 @@
import { EMAIL_DOCUMENT_CAPABILITIES } from '@/advanced-text-editor/constants/EmailDocumentCapabilities';
import { FULL_RICH_TEXT_CAPABILITIES } from '@/advanced-text-editor/constants/FullRichTextCapabilities';
import { type AdvancedTextEditorPreset } from '@/advanced-text-editor/types/AdvancedTextEditorPreset';
// One entry per surface that renders the TipTap editor today. Add a surface
@@ -9,21 +11,24 @@ export const ADVANCED_TEXT_EDITOR_PRESETS = {
// it to email-safe HTML at send time, like the workflow email node.
campaignBody: {
contentType: 'json',
chrome: 'document',
chrome: 'canvas',
minHeight: 0,
enableFullScreen: false,
capabilities: [...EMAIL_DOCUMENT_CAPABILITIES, 'campaignVariables'],
},
inlineEmailBody: {
contentType: 'html',
chrome: 'field',
minHeight: 120,
enableFullScreen: true,
capabilities: FULL_RICH_TEXT_CAPABILITIES,
},
workflowEmailBody: {
contentType: 'json',
chrome: 'field',
minHeight: 340,
enableFullScreen: true,
capabilities: FULL_RICH_TEXT_CAPABILITIES,
},
// The RICH_TEXT form input. Its stored value is still BlockNote JSON; only
// the editing surface is TipTap today.
@@ -32,12 +37,21 @@ export const ADVANCED_TEXT_EDITOR_PRESETS = {
chrome: 'field',
minHeight: 340,
enableFullScreen: true,
capabilities: FULL_RICH_TEXT_CAPABILITIES,
},
aiInstructions: {
contentType: 'markdown',
chrome: 'field',
minHeight: 120,
enableFullScreen: true,
capabilities: FULL_RICH_TEXT_CAPABILITIES,
},
aiChat: {
contentType: 'json',
chrome: 'document',
minHeight: 0,
enableFullScreen: false,
capabilities: ['mentions'],
},
} as const satisfies Record<string, AdvancedTextEditorPreset>;
@@ -0,0 +1,11 @@
import { type AdvancedTextEditorCapability } from '@/advanced-text-editor/types/AdvancedTextEditorCapability';
export const EMAIL_DOCUMENT_CAPABILITIES = [
'basicMarks',
'headings',
'lists',
'links',
'images',
'slashCommand',
'blocks',
] as const satisfies readonly AdvancedTextEditorCapability[];
@@ -0,0 +1,12 @@
import { type AdvancedTextEditorCapability } from '@/advanced-text-editor/types/AdvancedTextEditorCapability';
export const FULL_RICH_TEXT_CAPABILITIES: readonly AdvancedTextEditorCapability[] =
[
'basicMarks',
'headings',
'lists',
'links',
'images',
'variables',
'slashCommand',
];
@@ -0,0 +1,77 @@
/* oxlint-disable twenty/no-hardcoded-colors --
default styles are literal inline CSS shipped inside emails, where theme
variables do not exist */
import { mergeAttributes, Node } from '@tiptap/core';
import { readBlockStyleAttribute } from '@/advanced-text-editor/extensions/blocks/readBlockStyleAttribute';
import { inlineStyleToCss } from '@/advanced-text-editor/utils/inlineStyleToCss';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
const DEFAULT_BUTTON_STYLE: Record<string, string> = {
backgroundColor: '#1961ed',
color: '#ffffff',
paddingTop: '10px',
paddingRight: '20px',
paddingBottom: '10px',
paddingLeft: '20px',
borderTopLeftRadius: '6px',
borderTopRightRadius: '6px',
borderBottomRightRadius: '6px',
borderBottomLeftRadius: '6px',
textDecoration: 'none',
display: 'inline-block',
};
export const ButtonNode = Node.create({
name: TIPTAP_NODE_TYPES.BUTTON,
group: 'block',
content: 'text*',
marks: '',
defining: true,
isolating: true,
addAttributes() {
return {
href: {
default: '',
parseHTML: (element) => element.getAttribute('data-href'),
renderHTML: (attributes) => ({ 'data-href': attributes.href }),
},
align: {
default: 'left',
parseHTML: (element) =>
element.parentElement?.style.textAlign || 'left',
renderHTML: () => ({}),
},
style: {
default: DEFAULT_BUTTON_STYLE,
parseHTML: readBlockStyleAttribute,
renderHTML: (attributes) => ({
style: inlineStyleToCss(attributes.style),
'data-style': JSON.stringify(attributes.style ?? {}),
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-button-block]' }];
},
renderHTML({ node, HTMLAttributes }) {
return [
'div',
{
class: 'block-button-wrapper',
style: `text-align: ${node.attrs.align ?? 'left'};`,
},
[
'div',
mergeAttributes(HTMLAttributes, {
'data-button-block': 'true',
class: 'block-button',
}),
0,
],
];
},
});
@@ -0,0 +1,39 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { readBlockStyleAttribute } from '@/advanced-text-editor/extensions/blocks/readBlockStyleAttribute';
import { inlineStyleToCss } from '@/advanced-text-editor/utils/inlineStyleToCss';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
export const ColumnNode = Node.create({
name: TIPTAP_NODE_TYPES.COLUMN,
content: 'block+',
defining: true,
isolating: true,
addAttributes() {
return {
style: {
default: {},
parseHTML: readBlockStyleAttribute,
renderHTML: (attributes) => ({
style: inlineStyleToCss(attributes.style),
'data-style': JSON.stringify(attributes.style ?? {}),
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-block-column]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-block-column': 'true',
class: 'block-column',
}),
0,
];
},
});
@@ -0,0 +1,40 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { readBlockStyleAttribute } from '@/advanced-text-editor/extensions/blocks/readBlockStyleAttribute';
import { inlineStyleToCss } from '@/advanced-text-editor/utils/inlineStyleToCss';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
export const ColumnsNode = Node.create({
name: TIPTAP_NODE_TYPES.COLUMNS,
group: 'block',
content: `${TIPTAP_NODE_TYPES.COLUMN}{2,4}`,
defining: true,
isolating: true,
addAttributes() {
return {
style: {
default: {},
parseHTML: readBlockStyleAttribute,
renderHTML: (attributes) => ({
style: inlineStyleToCss(attributes.style),
'data-style': JSON.stringify(attributes.style ?? {}),
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-block-columns]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-block-columns': 'true',
class: 'block-columns',
}),
0,
];
},
});
@@ -0,0 +1,48 @@
/* oxlint-disable twenty/no-hardcoded-colors --
default styles are literal inline CSS shipped inside emails, where theme
variables do not exist */
import { mergeAttributes, Node } from '@tiptap/core';
import { readBlockStyleAttribute } from '@/advanced-text-editor/extensions/blocks/readBlockStyleAttribute';
import { inlineStyleToCss } from '@/advanced-text-editor/utils/inlineStyleToCss';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
export const DividerNode = Node.create({
name: TIPTAP_NODE_TYPES.DIVIDER,
group: 'block',
atom: true,
addAttributes() {
return {
style: {
default: {
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: '#e1e1e1',
marginTop: '16px',
marginRight: '0px',
marginBottom: '16px',
marginLeft: '0px',
},
parseHTML: readBlockStyleAttribute,
renderHTML: (attributes) => ({
style: inlineStyleToCss(attributes.style),
'data-style': JSON.stringify(attributes.style ?? {}),
}),
},
};
},
parseHTML() {
return [{ tag: 'hr[data-block-divider]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'hr',
mergeAttributes(HTMLAttributes, {
'data-block-divider': 'true',
class: 'block-divider',
}),
];
},
});
@@ -0,0 +1,36 @@
import { Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
import { HtmlNodeView } from '@/advanced-text-editor/extensions/blocks/HtmlNodeView';
const DEFAULT_HTML_BLOCK =
'<p style="margin: 0;">Edit this HTML in the block settings panel.</p>';
export const HtmlNode = Node.create({
name: TIPTAP_NODE_TYPES.HTML,
group: 'block',
atom: true,
addAttributes() {
return {
html: {
default: DEFAULT_HTML_BLOCK,
parseHTML: (element) => element.getAttribute('data-html'),
renderHTML: (attributes) => ({ 'data-html': attributes.html }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-html-block]' }];
},
renderHTML() {
return ['div', { 'data-html-block': 'true', class: 'block-html' }];
},
addNodeView() {
return ReactNodeViewRenderer(HtmlNodeView);
},
});
@@ -0,0 +1,30 @@
import { styled } from '@linaria/react';
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { sanitizeHtmlPreview } from '@/advanced-text-editor/utils/sanitizeHtmlPreview';
type HtmlNodeViewProps = Pick<NodeViewProps, 'node'>;
const StyledPreview = styled.div`
border-radius: ${themeCssVariables.border.radius.sm};
outline: 1px dashed transparent;
outline-offset: 2px;
&:hover {
outline-color: ${themeCssVariables.border.color.medium};
}
`;
export const HtmlNodeView = ({ node }: HtmlNodeViewProps) => {
const html = typeof node.attrs.html === 'string' ? node.attrs.html : '';
return (
<NodeViewWrapper>
<StyledPreview
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: sanitizeHtmlPreview(html) }}
/>
</NodeViewWrapper>
);
};
@@ -0,0 +1,45 @@
import { mergeAttributes, Node } from '@tiptap/core';
import { readBlockStyleAttribute } from '@/advanced-text-editor/extensions/blocks/readBlockStyleAttribute';
import { inlineStyleToCss } from '@/advanced-text-editor/utils/inlineStyleToCss';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
export const SectionNode = Node.create({
name: TIPTAP_NODE_TYPES.SECTION,
group: 'block',
content: 'block+',
defining: true,
isolating: true,
addAttributes() {
return {
style: {
default: {
paddingTop: '12px',
paddingRight: '12px',
paddingBottom: '12px',
paddingLeft: '12px',
},
parseHTML: readBlockStyleAttribute,
renderHTML: (attributes) => ({
style: inlineStyleToCss(attributes.style),
'data-style': JSON.stringify(attributes.style ?? {}),
}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-block-section]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-block-section': 'true',
class: 'block-section',
}),
0,
];
},
});
@@ -0,0 +1,18 @@
import { Document } from '@tiptap/extension-document';
import {
EMAIL_DOCUMENT_SCHEMA_VERSION,
CANVAS_THEME_DEFAULTS,
} from 'twenty-shared/utils';
export const ThemedDocument = Document.extend({
addAttributes() {
return {
canvasTheme: {
default: CANVAS_THEME_DEFAULTS,
},
schemaVersion: {
default: EMAIL_DOCUMENT_SCHEMA_VERSION,
},
};
},
});
@@ -0,0 +1,11 @@
import { getBlockStyle } from '@/advanced-text-editor/utils/getBlockStyle';
export const readBlockStyleAttribute = (
element: HTMLElement,
): Record<string, string> => {
try {
return getBlockStyle(JSON.parse(element.getAttribute('data-style') ?? ''));
} catch {
return {};
}
};
@@ -0,0 +1,13 @@
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
type CampaignVariableChipProps = Pick<NodeViewProps, 'node'>;
export const CampaignVariableChip = ({ node }: CampaignVariableChipProps) => {
const variable =
typeof node.attrs.variable === 'string' ? node.attrs.variable : '';
return (
<NodeViewWrapper as="span" data-drag-handle>
<span className="variable-tag">{variable}</span>
</NodeViewWrapper>
);
};
@@ -0,0 +1,10 @@
import { ReactNodeViewRenderer } from '@tiptap/react';
import { CampaignVariableChip } from '@/advanced-text-editor/extensions/campaign-variables/CampaignVariableChip';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
export const CampaignVariableTag = VariableTag.extend({
addNodeView() {
return ReactNodeViewRenderer(CampaignVariableChip);
},
});
@@ -12,6 +12,21 @@ export const ResizableImage = TiptapImage.extend<ImageOptions>({
align: {
default: 'left',
},
width: {
default: null,
},
fileId: {
default: null,
parseHTML: (element) => element.getAttribute('data-file-id'),
renderHTML: (attributes) =>
attributes.fileId ? { 'data-file-id': attributes.fileId } : {},
},
href: {
default: '',
parseHTML: (element) => element.getAttribute('data-href'),
renderHTML: (attributes) =>
attributes.href ? { 'data-href': attributes.href } : {},
},
};
},
@@ -10,11 +10,14 @@ const IMAGE_MAX_WIDTH = 600;
const StyledNodeViewWrapperContainer = styled.div<{
align?: string;
}>`
display: flex;
height: 100%;
margin-left: ${({ align }) =>
align === 'left' ? '0' : align === 'center' ? 'auto' : 'unset'};
margin-right: ${({ align }) =>
align === 'right' ? '0' : align === 'center' ? 'auto' : 'unset'};
justify-content: ${({ align }) =>
align === 'center'
? 'center'
: align === 'right'
? 'flex-end'
: 'flex-start'};
`;
const StyledImageWrapper = styled.div<{ width?: number }>`
@@ -3,8 +3,9 @@ import {
type UploadImagePluginProps,
} from '@/advanced-text-editor/extensions/resizable-image/UploadImagePlugin';
import { Extension } from '@tiptap/core';
import { EMAIL_IMAGE_MIME_TYPES } from 'twenty-shared/constants';
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'> & {};
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'>;
type UploadImageStorage = {
placeholderImages: Set<string>;
@@ -24,13 +25,7 @@ export const UploadImageExtension = Extension.create<
addOptions: () => {
return {
allowedMimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
],
allowedMimeTypes: EMAIL_IMAGE_MIME_TYPES,
onImageUpload: undefined,
onImageUploadError: undefined,
};
@@ -3,10 +3,12 @@ import { type Node } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { type EditorView } from '@tiptap/pm/view';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
export type UploadImagePluginProps = {
editor: Editor;
allowedMimeTypes?: string[];
onImageUpload?: (file: File) => Promise<string>;
allowedMimeTypes?: readonly string[];
onImageUpload?: (file: File) => Promise<UploadedImage>;
onImageUploadError?: (error: Error, file: File) => void;
};
@@ -34,7 +36,7 @@ export const UploadImagePlugin = (options: UploadImagePluginProps) => {
view.dispatch(transaction);
onImageUpload?.(file)
.then((uploadedSrc) => {
.then((uploadedImage) => {
const updateTr = view.state.tr;
const predicate = (node: Node) =>
@@ -44,7 +46,8 @@ export const UploadImagePlugin = (options: UploadImagePluginProps) => {
if (predicate(node)) {
updateTr.setNodeMarkup(pos, undefined, {
...node.attrs,
src: uploadedSrc,
fileId: uploadedImage.fileId ?? null,
src: uploadedImage.url,
});
return false;
}
@@ -1,16 +1,30 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Editor, type Range } from '@tiptap/core';
import { isDefined, TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
import {
type IconComponent,
IconBox,
IconClick,
IconCode,
IconColumns,
IconH1,
IconH2,
IconH3,
IconList,
IconListNumbers,
IconMinus,
IconPilcrow,
} from 'twenty-ui/icon';
const hasSchemaNode = (editor: Editor, nodeName: string) =>
isDefined(editor.schema.nodes[nodeName]);
const columnJson = () => ({
type: TIPTAP_NODE_TYPES.COLUMN,
content: [{ type: TIPTAP_NODE_TYPES.PARAGRAPH }],
});
export type SlashCommandConfig = {
id: string;
title: MessageDescriptor;
@@ -68,6 +82,114 @@ export const DEFAULT_SLASH_COMMANDS: SlashCommandConfig[] = [
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(),
},
{
id: 'section',
title: msg`Section`,
description: msg`Styled container for email content`,
icon: IconBox,
keywords: [msg`section`, msg`container`, msg`block`, msg`background`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.SECTION),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.SECTION),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: TIPTAP_NODE_TYPES.SECTION,
content: [{ type: TIPTAP_NODE_TYPES.PARAGRAPH }],
})
.run(),
},
{
id: 'emailColumns2',
title: msg`2 Columns`,
description: msg`Two columns side by side`,
icon: IconColumns,
keywords: [msg`columns`, msg`two`, msg`layout`, msg`row`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.COLUMNS),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.COLUMNS),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: TIPTAP_NODE_TYPES.COLUMNS,
content: [columnJson(), columnJson()],
})
.run(),
},
{
id: 'emailColumns3',
title: msg`3 Columns`,
description: msg`Three columns side by side`,
icon: IconColumns,
keywords: [msg`columns`, msg`three`, msg`layout`, msg`row`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.COLUMNS),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.COLUMNS),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: TIPTAP_NODE_TYPES.COLUMNS,
content: [columnJson(), columnJson(), columnJson()],
})
.run(),
},
{
id: 'button',
title: msg`Button`,
description: msg`Call-to-action button`,
icon: IconClick,
keywords: [msg`button`, msg`cta`, msg`link`, msg`action`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.BUTTON),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.BUTTON),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: TIPTAP_NODE_TYPES.BUTTON,
content: [{ type: TIPTAP_NODE_TYPES.TEXT, text: 'Click here' }],
})
.run(),
},
{
id: 'html',
title: msg`HTML`,
description: msg`Raw HTML embedded in the email`,
icon: IconCode,
keywords: [msg`html`, msg`embed`, msg`code`, msg`custom`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.HTML),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.HTML),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({ type: TIPTAP_NODE_TYPES.HTML })
.run(),
},
{
id: 'divider',
title: msg`Divider`,
description: msg`Horizontal separator line`,
icon: IconMinus,
keywords: [msg`divider`, msg`separator`, msg`line`, msg`hr`],
getIsActive: (editor) => editor.isActive(TIPTAP_NODE_TYPES.DIVIDER),
getIsVisible: (editor) => hasSchemaNode(editor, TIPTAP_NODE_TYPES.DIVIDER),
getOnSelect: (editor, range) => () =>
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({ type: TIPTAP_NODE_TYPES.DIVIDER })
.run(),
},
{
id: 'bulletList',
title: msg`Bullet List`,
@@ -1,45 +1,33 @@
import { ResizableImage } from '@/advanced-text-editor/extensions/resizable-image/ResizableImage';
import { UploadImageExtension } from '@/advanced-text-editor/extensions/resizable-image/UploadImageExtension';
import { SlashCommand } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import {
ADVANCED_TEXT_EDITOR_PRESETS,
type AdvancedTextEditorPresetName,
} from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
import { buildAdvancedTextEditorExtensions } from '@/advanced-text-editor/utils/buildAdvancedTextEditorExtensions';
import { getInitialAdvancedTextEditorContent } from '@/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import { t } from '@lingui/core/macro';
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 { ListKit } from '@tiptap/extension-list';
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 } from '@tiptap/extensions/drop-cursor';
import { Placeholder } from '@tiptap/extensions/placeholder';
import { UndoRedo } from '@tiptap/extensions/undo-redo';
import { type Editor, useEditor } from '@tiptap/react';
import { type Content } from '@tiptap/core';
import { type Editor, type EditorOptions, useEditor } from '@tiptap/react';
import { marked } from 'marked';
import { type DependencyList, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type AdvancedTextEditorContentType = 'json' | 'html' | 'markdown';
type UseAdvancedTextEditorProps = {
preset: AdvancedTextEditorPresetName;
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>;
onImageUpload?: (file: File) => Promise<UploadedImage>;
onImageUploadError?: (error: Error, file: File) => void;
enableSlashCommand?: boolean;
contentType?: AdvancedTextEditorContentType;
content?: Content;
editorProps?: EditorOptions['editorProps'];
};
export const useAdvancedTextEditor = (
{
preset,
placeholder,
readonly,
defaultValue,
@@ -48,60 +36,37 @@ export const useAdvancedTextEditor = (
onBlur,
onImageUpload,
onImageUploadError,
enableSlashCommand,
contentType = 'json',
content,
editorProps,
}: UseAdvancedTextEditorProps,
dependencies?: DependencyList,
) => {
const isMarkdownMode = contentType === 'markdown';
const { contentType, capabilities } = ADVANCED_TEXT_EDITOR_PRESETS[preset];
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: placeholder ?? t`Enter text or Type '/' for commands`,
() =>
buildAdvancedTextEditorExtensions({
capabilities,
context: {
onImageUpload,
onImageUploadError,
},
placeholder,
readonly,
}),
VariableTag,
HardBreak.configure({
keepMarks: false,
}),
UndoRedo,
Bold,
Italic,
Strike,
Underline,
Heading.configure({
levels: [1, 2, 3],
}),
Link.configure({
openOnClick: false,
}),
ResizableImage,
Dropcursor,
ListKit,
UploadImageExtension.configure({
onImageUpload,
onImageUploadError,
}),
...(!readonly && enableSlashCommand !== false ? [SlashCommand] : []),
],
[
placeholder,
onImageUpload,
onImageUploadError,
readonly,
enableSlashCommand,
],
[capabilities, placeholder, onImageUpload, onImageUploadError, readonly],
);
const getEditorContent = () => {
const getEditorContent = (): Content | undefined => {
if (isDefined(content)) {
return content;
}
if (!isDefined(defaultValue)) {
return undefined;
}
if (isMarkdownMode) {
if (contentType === 'markdown') {
// Convert markdown to HTML, then TipTap will parse the HTML
return marked.parse(defaultValue, { async: false }) as string;
}
@@ -130,6 +95,7 @@ export const useAdvancedTextEditor = (
editorProps: {
scrollThreshold: 60,
scrollMargin: 60,
...editorProps,
},
injectCSS: false,
},
@@ -1,3 +1,4 @@
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
import { useLingui } from '@lingui/react/macro';
import { type Editor, useEditorState } from '@tiptap/react';
import {
@@ -17,9 +18,21 @@ export type TurnIntoBlockOptions = {
icon: IconComponent;
};
const HEADING_ICONS: Record<number, IconComponent> = {
1: IconH1,
2: IconH2,
3: IconH3,
};
export const useTurnIntoBlockOptions = (editor: Editor) => {
const { t } = useLingui();
const headingTitles: Record<number, string> = {
1: t`Heading 1`,
2: t`Heading 2`,
3: t`Heading 3`,
};
return useEditorState({
editor,
selector: ({ editor }): TurnIntoBlockOptions[] => [
@@ -37,48 +50,22 @@ export const useTurnIntoBlockOptions = (editor: Editor) => {
return editor.isActive('paragraph');
},
},
{
id: 'heading1',
title: t`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: t`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: t`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 });
},
},
...(hasEditorExtension(editor, 'heading')
? ([1, 2, 3] as const).map((level) => ({
id: `heading${level}`,
title: headingTitles[level],
icon: HEADING_ICONS[level],
onClick: () => {
return editor.chain().focus().setHeading({ level }).run();
},
disabled: () => {
return !editor.can().setHeading({ level });
},
isActive: () => {
return editor.isActive('heading', { level });
},
}))
: []),
],
});
};
@@ -0,0 +1,55 @@
import { t } from '@lingui/core/macro';
import { EMAIL_IMAGE_MIME_TYPES } from 'twenty-shared/constants';
import { FileFolder } from '~/generated-metadata/graphql';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/maxAttachmentSize';
import { formatFileSize } from '@/file/utils/formatFileSize';
import { useDirectFileUpload } from '@/file/hooks/useDirectFileUpload';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { logError } from '~/utils/logError';
export const useUploadEmailImage = () => {
const { uploadFile } = useDirectFileUpload();
const { enqueueErrorSnackBar } = useSnackBar();
const uploadEmailImage = async (file: File): Promise<UploadedImage> => {
if (
!EMAIL_IMAGE_MIME_TYPES.includes(
file.type as (typeof EMAIL_IMAGE_MIME_TYPES)[number],
)
) {
enqueueErrorSnackBar({ message: t`Unsupported image format` });
throw new Error(`Unsupported email image MIME type: ${file.type}`);
}
if (file.size > MAX_ATTACHMENT_SIZE) {
const fileName = file.name;
const maxUploadSize = formatFileSize(MAX_ATTACHMENT_SIZE);
enqueueErrorSnackBar({
message: t`Image "${fileName}" exceeds ${maxUploadSize}`,
});
throw new Error('Email image exceeds the maximum upload size');
}
try {
const uploadedFile = await uploadFile(file, {
fileFolder: FileFolder.EmailImage,
});
return { fileId: uploadedFile.id, url: uploadedFile.url };
} catch (error) {
const fileName = file.name;
logError(`Failed to upload email image "${fileName}": ${error}`);
enqueueErrorSnackBar({ message: t`Failed to upload "${fileName}"` });
throw error;
}
};
return { uploadEmailImage };
};
@@ -0,0 +1,7 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type Editor } from '@tiptap/core';
export const activeEmailEditorState = createAtomState<Editor | null>({
key: 'activeEmailEditorState',
defaultValue: null,
});
@@ -0,0 +1,11 @@
export type AdvancedTextEditorCapability =
| 'basicMarks'
| 'headings'
| 'lists'
| 'links'
| 'images'
| 'variables'
| 'campaignVariables'
| 'mentions'
| 'slashCommand'
| 'blocks';
@@ -0,0 +1 @@
export type AdvancedTextEditorContentType = 'json' | 'html' | 'markdown';
@@ -1,13 +1,14 @@
import { type AdvancedTextEditorContentType } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { type AdvancedTextEditorCapability } from '@/advanced-text-editor/types/AdvancedTextEditorCapability';
import { type AdvancedTextEditorContentType } from '@/advanced-text-editor/types/AdvancedTextEditorContentType';
// 'field' keeps the bordered form-field chrome. 'document' drops the border so
// the editor fills whatever container it is given and reads as page content
// rather than as an input.
export type AdvancedTextEditorChrome = 'field' | 'document';
export type AdvancedTextEditorChrome = 'field' | 'document' | 'canvas';
export type AdvancedTextEditorPreset = {
contentType: AdvancedTextEditorContentType;
chrome: AdvancedTextEditorChrome;
minHeight: number;
enableFullScreen: boolean;
capabilities: readonly AdvancedTextEditorCapability[];
};
@@ -0,0 +1,4 @@
export type UploadedImage = {
fileId?: string;
url: string;
};
@@ -0,0 +1,110 @@
import { ADVANCED_TEXT_EDITOR_PRESETS } from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
import { buildAdvancedTextEditorExtensions } from '@/advanced-text-editor/utils/buildAdvancedTextEditorExtensions';
const getExtensionNames = (
extensions: ReturnType<typeof buildAdvancedTextEditorExtensions>,
) => extensions.map((extension) => extension.name);
describe('buildAdvancedTextEditorExtensions', () => {
it('should always include core extensions', () => {
const extensions = buildAdvancedTextEditorExtensions({
capabilities: [],
context: {},
placeholder: undefined,
readonly: false,
});
expect(getExtensionNames(extensions)).toEqual(
expect.arrayContaining([
'doc',
'paragraph',
'text',
'placeholder',
'hardBreak',
'undoRedo',
'dropCursor',
]),
);
});
it('should build the structured email extension set for campaign bodies', () => {
const extensions = buildAdvancedTextEditorExtensions({
capabilities: ADVANCED_TEXT_EDITOR_PRESETS.campaignBody.capabilities,
context: {},
placeholder: undefined,
readonly: false,
});
expect(getExtensionNames(extensions)).toEqual(
expect.arrayContaining([
'bold',
'italic',
'strike',
'underline',
'heading',
'listKit',
'link',
'image',
'uploadImage',
'variableTag',
'slash-command',
'section',
'columns',
'column',
'button',
'divider',
'html',
]),
);
});
it('should only add mention extensions on top of core for the aiChat preset', () => {
const extensions = buildAdvancedTextEditorExtensions({
capabilities: ADVANCED_TEXT_EDITOR_PRESETS.aiChat.capabilities,
context: {},
placeholder: undefined,
readonly: false,
});
const extensionNames = getExtensionNames(extensions);
expect(extensionNames).toEqual(
expect.arrayContaining(['mentionTag', 'mention-suggestion']),
);
expect(extensionNames).not.toEqual(expect.arrayContaining(['bold']));
expect(extensionNames).not.toEqual(expect.arrayContaining(['heading']));
expect(extensionNames).not.toEqual(
expect.arrayContaining(['slash-command']),
);
});
it('should drop the slash command when readonly', () => {
const extensions = buildAdvancedTextEditorExtensions({
capabilities: ['slashCommand'],
context: {},
placeholder: undefined,
readonly: true,
});
expect(getExtensionNames(extensions)).not.toEqual(
expect.arrayContaining(['slash-command']),
);
});
it('should pass image upload callbacks to the upload extension', () => {
const onImageUpload = jest.fn();
const extensions = buildAdvancedTextEditorExtensions({
capabilities: ['images'],
context: { onImageUpload },
placeholder: undefined,
readonly: false,
});
const uploadImageExtension = extensions.find(
(extension) => extension.name === 'uploadImage',
);
expect(uploadImageExtension?.options.onImageUpload).toBe(onImageUpload);
});
});
@@ -0,0 +1,103 @@
import { ButtonNode } from '@/advanced-text-editor/extensions/blocks/ButtonNode';
import { ColumnNode } from '@/advanced-text-editor/extensions/blocks/ColumnNode';
import { ColumnsNode } from '@/advanced-text-editor/extensions/blocks/ColumnsNode';
import { DividerNode } from '@/advanced-text-editor/extensions/blocks/DividerNode';
import { SectionNode } from '@/advanced-text-editor/extensions/blocks/SectionNode';
import { getBlockSelectionTarget } from '@/advanced-text-editor/utils/getBlockSelectionTarget';
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { NodeSelection, TextSelection } from '@tiptap/pm/state';
const createEditor = (content: object) =>
new Editor({
extensions: [
Document,
Paragraph,
Text,
SectionNode,
ColumnsNode,
ColumnNode,
ButtonNode,
DividerNode,
],
content,
});
describe('getBlockSelectionTarget', () => {
it('should return null when the cursor is in plain content', () => {
const editor = createEditor({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] },
],
});
expect(getBlockSelectionTarget(editor)).toBeNull();
editor.destroy();
});
it('should target the section containing the cursor', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'section',
attrs: { style: { padding: '24px' } },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'inside' }] },
],
},
],
});
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
);
const target = getBlockSelectionTarget(editor);
expect(target?.nodeType).toBe('section');
expect(target?.attrs.style).toEqual({ padding: '24px' });
editor.destroy();
});
it('should prefer the deepest block: a button inside a section', () => {
const editor = createEditor({
type: 'doc',
content: [
{
type: 'section',
content: [
{
type: 'button',
content: [{ type: 'text', text: 'Click' }],
},
],
},
],
});
editor.view.dispatch(
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3)),
);
expect(getBlockSelectionTarget(editor)?.nodeType).toBe('button');
editor.destroy();
});
it('should target a node-selected divider', () => {
const editor = createEditor({
type: 'doc',
content: [{ type: 'paragraph' }, { type: 'divider' }],
});
editor.view.dispatch(
editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 2)),
);
expect(getBlockSelectionTarget(editor)?.nodeType).toBe('divider');
editor.destroy();
});
});
@@ -0,0 +1,26 @@
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
import { Editor } from '@tiptap/core';
import { Bold } from '@tiptap/extension-bold';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
describe('hasEditorExtension', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Bold],
});
afterAll(() => {
editor.destroy();
});
it('should return true for a loaded extension', () => {
expect(hasEditorExtension(editor, 'bold')).toBe(true);
expect(hasEditorExtension(editor, 'paragraph')).toBe(true);
});
it('should return false for an extension that is not loaded', () => {
expect(hasEditorExtension(editor, 'italic')).toBe(false);
expect(hasEditorExtension(editor, 'heading')).toBe(false);
});
});
@@ -0,0 +1,15 @@
import { parseCssSizeValue } from '@/advanced-text-editor/utils/parseCssSizeValue';
describe('parseCssSizeValue', () => {
it('should split amount and unit', () => {
expect(parseCssSizeValue('600px')).toEqual({ amount: '600', unit: 'px' });
expect(parseCssSizeValue('50%')).toEqual({ amount: '50', unit: '%' });
expect(parseCssSizeValue('1.5em')).toEqual({ amount: '1.5', unit: 'em' });
});
it('should return an empty amount for non-numeric values', () => {
expect(parseCssSizeValue('auto')).toEqual({ amount: '', unit: 'px' });
expect(parseCssSizeValue('')).toEqual({ amount: '', unit: 'px' });
expect(parseCssSizeValue(undefined)).toEqual({ amount: '', unit: 'px' });
});
});
@@ -0,0 +1,66 @@
import { sanitizeHtmlPreview } from '@/advanced-text-editor/utils/sanitizeHtmlPreview';
describe('sanitizeHtmlPreview', () => {
it('should keep benign presentational markup', () => {
const html =
'<table><tbody><tr><td style="padding: 8px">Hi</td></tr></tbody></table>';
expect(sanitizeHtmlPreview(html)).toContain('padding: 8px');
expect(sanitizeHtmlPreview(html)).toContain('Hi');
});
it('should remove script elements', () => {
expect(sanitizeHtmlPreview('<p>a</p><script>alert(1)</script>')).toBe(
'<p>a</p>',
);
});
it('should remove elements that execute without a click', () => {
expect(
sanitizeHtmlPreview('<iframe src="javascript:alert(1)"></iframe>'),
).toBe('');
expect(sanitizeHtmlPreview('<object data="x"></object>')).toBe('');
expect(sanitizeHtmlPreview('<embed src="x">')).toBe('');
});
it('should strip inline handlers even without a leading space', () => {
expect(
sanitizeHtmlPreview('<img/onerror="alert(1)" src="x.png">'),
).not.toContain('onerror');
expect(
sanitizeHtmlPreview('<div ONCLICK="alert(1)">x</div>'),
).not.toContain('ONCLICK');
});
it('should drop javascript: links, including entity-encoded ones', () => {
expect(
sanitizeHtmlPreview('<a href="javascript:alert(1)">x</a>'),
).not.toContain('href');
expect(
sanitizeHtmlPreview('<a href="jav&#x61;script:alert(1)">x</a>'),
).not.toContain('href');
expect(
sanitizeHtmlPreview('<a href="java\nscript:alert(1)">x</a>'),
).not.toContain('href');
});
it('should keep ordinary links and data images', () => {
expect(
sanitizeHtmlPreview('<a href="https://example.com">x</a>'),
).toContain('href="https://example.com"');
expect(
sanitizeHtmlPreview('<img src="data:image/png;base64,AAAA">'),
).toContain('data:image/png');
});
it('should strip srcdoc and formaction attributes', () => {
expect(
sanitizeHtmlPreview('<div srcdoc="<script>x</script>">a</div>'),
).not.toContain('srcdoc');
expect(
sanitizeHtmlPreview(
'<button formaction="javascript:alert(1)">x</button>',
),
).not.toContain('formaction');
});
});
@@ -0,0 +1,45 @@
import { serializeAdvancedTextEditorContent } from '@/advanced-text-editor/utils/serializeAdvancedTextEditorContent';
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
describe('serializeAdvancedTextEditorContent', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p>Hello</p>',
});
afterAll(() => {
editor.destroy();
});
it('should serialize to a JSON string for json content', () => {
const serialized = serializeAdvancedTextEditorContent({
editor,
contentType: 'json',
});
expect(JSON.parse(serialized)).toMatchObject({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello' }],
},
],
});
});
it('should serialize to HTML for html content', () => {
expect(
serializeAdvancedTextEditorContent({ editor, contentType: 'html' }),
).toBe('<p>Hello</p>');
});
it('should serialize to HTML for markdown content', () => {
expect(
serializeAdvancedTextEditorContent({ editor, contentType: 'markdown' }),
).toBe('<p>Hello</p>');
});
});
@@ -0,0 +1,37 @@
import {
ADVANCED_TEXT_EDITOR_CAPABILITY_EXTENSIONS,
type AdvancedTextEditorExtensionContext,
} from '@/advanced-text-editor/constants/AdvancedTextEditorCapabilityExtensions';
import { buildAdvancedTextEditorCoreExtensions } from '@/advanced-text-editor/constants/AdvancedTextEditorCoreExtensions';
import { ThemedDocument } from '@/advanced-text-editor/extensions/blocks/ThemedDocument';
import { type AdvancedTextEditorCapability } from '@/advanced-text-editor/types/AdvancedTextEditorCapability';
import { type AnyExtension } from '@tiptap/core';
export const buildAdvancedTextEditorExtensions = ({
capabilities,
context,
placeholder,
readonly,
}: {
capabilities: readonly AdvancedTextEditorCapability[];
context: AdvancedTextEditorExtensionContext;
placeholder: string | undefined;
readonly: boolean | undefined;
}): AnyExtension[] => {
const coreExtensions = buildAdvancedTextEditorCoreExtensions({ placeholder });
return [
...(capabilities.includes('blocks')
? coreExtensions.map((extension) =>
extension.name === 'doc' ? ThemedDocument : extension,
)
: coreExtensions),
...capabilities
.filter(
(capability) => !(readonly === true && capability === 'slashCommand'),
)
.flatMap((capability) =>
ADVANCED_TEXT_EDITOR_CAPABILITY_EXTENSIONS[capability](context),
),
];
};
@@ -0,0 +1,50 @@
import { type Editor } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
const INSPECTABLE_NODE_TYPES: readonly string[] = [
TIPTAP_NODE_TYPES.SECTION,
TIPTAP_NODE_TYPES.COLUMNS,
TIPTAP_NODE_TYPES.COLUMN,
TIPTAP_NODE_TYPES.BUTTON,
TIPTAP_NODE_TYPES.DIVIDER,
TIPTAP_NODE_TYPES.HTML,
TIPTAP_NODE_TYPES.IMAGE,
];
export type BlockSelectionTarget = {
nodeType: string;
pos: number;
attrs: Record<string, unknown>;
};
export const getBlockSelectionTarget = (
editor: Editor,
): BlockSelectionTarget | null => {
const { selection } = editor.state;
if (
selection instanceof NodeSelection &&
INSPECTABLE_NODE_TYPES.includes(selection.node.type.name)
) {
return {
nodeType: selection.node.type.name,
pos: selection.from,
attrs: { ...selection.node.attrs },
};
}
const { $from } = selection;
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (INSPECTABLE_NODE_TYPES.includes(node.type.name)) {
return {
nodeType: node.type.name,
pos: $from.before(depth),
attrs: { ...node.attrs },
};
}
}
return null;
};
@@ -0,0 +1,11 @@
export const getBlockStyle = (value: unknown): Record<string, string> => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',
),
);
};
@@ -0,0 +1,6 @@
import { type Editor } from '@tiptap/core';
export const hasEditorExtension = (editor: Editor, extensionName: string) =>
editor.extensionManager.extensions.some(
(extension) => extension.name === extensionName,
);
@@ -0,0 +1,13 @@
export const inlineStyleToCss = (style: unknown): string => {
if (typeof style !== 'object' || style === null) {
return '';
}
return Object.entries(style)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
.map(
([property, value]) =>
`${property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}: ${value};`,
)
.join(' ');
};
@@ -0,0 +1,16 @@
export type CssSizeValue = {
amount: string;
unit: 'px' | '%' | 'em';
};
const CSS_SIZE_PATTERN = /^(-?(?:\d+|\d*\.\d+))(px|%|em)$/;
export const parseCssSizeValue = (value: string | undefined): CssSizeValue => {
const match = (value ?? '').trim().match(CSS_SIZE_PATTERN);
if (!match) {
return { amount: '', unit: 'px' };
}
return { amount: match[1], unit: match[2] as CssSizeValue['unit'] };
};
@@ -0,0 +1,47 @@
const BLOCKED_ELEMENT_SELECTOR =
'script, iframe, frame, object, embed, link, meta, base, style, svg, math';
const URL_ATTRIBUTE_NAMES = ['href', 'src', 'xlink:href', 'action'];
const isBlockedUrl = (attributeName: string, rawValue: string): boolean => {
const value = rawValue.replace(/[\u0000-\u0020]/g, '').toLowerCase();
if (attributeName === 'src' && value.startsWith('data:image/')) {
return false;
}
return (
// oxlint-disable-next-line no-script-url -- this is the sanitizer's blocklist
value.startsWith('javascript:') ||
value.startsWith('vbscript:') ||
value.startsWith('data:')
);
};
export const sanitizeHtmlPreview = (html: string): string => {
const document = new DOMParser().parseFromString(html, 'text/html');
document
.querySelectorAll(BLOCKED_ELEMENT_SELECTOR)
.forEach((element) => element.remove());
document.body.querySelectorAll('*').forEach((element) => {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase();
if (name.startsWith('on') || name === 'srcdoc' || name === 'formaction') {
element.removeAttribute(attribute.name);
continue;
}
if (
URL_ATTRIBUTE_NAMES.includes(name) &&
isBlockedUrl(name, attribute.value)
) {
element.removeAttribute(attribute.name);
}
}
});
return document.body.innerHTML;
};
@@ -0,0 +1,16 @@
import { type AdvancedTextEditorContentType } from '@/advanced-text-editor/types/AdvancedTextEditorContentType';
import { type Editor } from '@tiptap/core';
export const serializeAdvancedTextEditorContent = ({
editor,
contentType,
}: {
editor: Editor;
contentType: AdvancedTextEditorContentType;
}): string => {
if (contentType === 'html' || contentType === 'markdown') {
return editor.getHTML();
}
return JSON.stringify(editor.getJSON());
};
@@ -1,13 +1,8 @@
import { t } from '@lingui/core/macro';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { Placeholder } from '@tiptap/extensions/placeholder';
import { useEditor } from '@tiptap/react';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import {
@@ -19,8 +14,6 @@ import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
import { MentionTag } from '@/mention/extensions/MentionTag';
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
@@ -55,26 +48,12 @@ export const useAiChatEditor = () => {
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
const initialContent = textToTiptapContent(initialDraft);
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder: t`Ask, search or make anything...`,
}),
HardBreak.configure({
keepMarks: false,
}),
MentionTag,
MentionSuggestion,
],
[],
);
const editor = useEditor({
const editor = useAdvancedTextEditor({
preset: 'aiChat',
placeholder: t`Ask, search or make anything...`,
readonly: false,
defaultValue: undefined,
content: initialContent,
extensions,
editorProps: {
handleKeyDown: (view, event) => {
if (event.key === 'Enter' && !event.shiftKey) {
@@ -95,7 +74,7 @@ export const useAiChatEditor = () => {
return false;
},
},
onUpdate: ({ editor: currentEditor }) => {
onUpdate: (currentEditor) => {
const text = turnIntoEmptyStringIfWhitespacesOnly(
currentEditor.getText({ blockSeparator: '\n' }),
);
@@ -120,7 +99,6 @@ export const useAiChatEditor = () => {
onBlur: () => {
removeFocusItemFromFocusStackById({ focusId: AI_CHAT_INPUT_ID });
},
injectCSS: false,
});
// Keep search function in sync via Tiptap extension storage,
@@ -5,6 +5,7 @@ import { NavigationEngineCommand } from '@/command-menu-item/engine-command/comp
import { ComposeCampaignCommand } from '@/command-menu-item/engine-command/global/components/ComposeCampaignCommand';
import { SendMessageCampaignSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/message-campaign/components/SendMessageCampaignSingleRecordCommand';
import { SendMessageCampaignTestSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/message-campaign/components/SendMessageCampaignTestSingleRecordCommand';
import { OpenEmailBlockSettingsSingleRecordCommand } from '@/command-menu-item/engine-command/record/components/OpenEmailBlockSettingsSingleRecordCommand';
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
@@ -262,6 +263,9 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
[EngineComponentKey.SEND_MESSAGE_CAMPAIGN_TEST]: (
<SendMessageCampaignTestSingleRecordCommand />
),
[EngineComponentKey.EMAIL_BLOCK_SETTINGS]: (
<OpenEmailBlockSettingsSingleRecordCommand />
),
// Deprecated keys kept for backward compatibility until migration runs
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
@@ -0,0 +1,14 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useOpenEmailBlockSettingsInSidePanel } from '@/side-panel/hooks/useOpenEmailBlockSettingsInSidePanel';
export const OpenEmailBlockSettingsSingleRecordCommand = () => {
const { openEmailBlockSettingsInSidePanel } =
useOpenEmailBlockSettingsInSidePanel();
return (
<HeadlessEngineCommandWrapperEffect
execute={openEmailBlockSettingsInSidePanel}
ready
/>
);
};
@@ -329,7 +329,6 @@ export const RecordDetailRelationSectionDropdownToMany = ({
createNewRecordAndOpenSidePanel,
createTargetRecord,
dropdownId,
fieldName,
isMorphJunction,
isJunctionRelation,
junctionConfig,
@@ -4,6 +4,8 @@ import {
type AdvancedTextEditorPresetName,
} from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
import { serializeAdvancedTextEditorContent } from '@/advanced-text-editor/utils/serializeAdvancedTextEditorContent';
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 { InputHint } from '@/ui/input/components/InputHint';
@@ -15,7 +17,8 @@ import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useId, useState } from 'react';
import { type Editor } from '@tiptap/core';
import { useEffect, useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconMaximize } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
@@ -97,13 +100,14 @@ type FormAdvancedTextFieldInputProps = {
readonly?: boolean;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
onImageUpload?: (file: File) => Promise<string>;
onImageUpload?: (file: File) => Promise<UploadedImage>;
onImageUploadError?: (error: Error, file: File) => void;
preset: AdvancedTextEditorPresetName;
// Escape hatch for surfaces that share a preset but need their own height.
minHeight?: number;
enableFullScreen?: boolean;
fullScreenBreadcrumbs?: BreadcrumbProps['links'];
onEditorReady?: (editor: Editor | null) => void;
};
export const FormAdvancedTextFieldInput = ({
@@ -121,6 +125,7 @@ export const FormAdvancedTextFieldInput = ({
minHeight,
enableFullScreen,
fullScreenBreadcrumbs,
onEditorReady,
}: FormAdvancedTextFieldInputProps) => {
const {
contentType,
@@ -143,17 +148,12 @@ export const FormAdvancedTextFieldInput = ({
const editor = useAdvancedTextEditor(
{
preset,
placeholder: placeholder,
readonly,
defaultValue,
contentType,
onUpdate: (editor) => {
if (contentType === 'markdown' || contentType === 'html') {
onChange?.(editor.getHTML());
} else {
const jsonContent = editor.getJSON();
onChange?.(JSON.stringify(jsonContent));
}
onChange?.(serializeAdvancedTextEditorContent({ editor, contentType }));
},
onFocus: () => {
pushFocusItemToFocusStack({
@@ -172,11 +172,18 @@ export const FormAdvancedTextFieldInput = ({
},
onImageUpload,
onImageUploadError,
enableSlashCommand: true,
},
[isFullScreen],
);
useEffect(() => {
onEditorReady?.(editor);
return () => {
onEditorReady?.(null);
};
}, [editor, onEditorReady]);
const handleEnterFullScreen = () => {
setIsFullScreen(true);
};
@@ -209,6 +216,10 @@ export const FormAdvancedTextFieldInput = ({
hasClosePageButton: !isMobile,
});
if (!isDefined(editor)) {
return null;
}
const fullScreenOverlay = isFullScreenEnabled
? renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
@@ -217,6 +228,7 @@ export const FormAdvancedTextFieldInput = ({
editor={editor}
readonly={readonly}
minHeight={editorMinHeight}
chrome={chrome}
/>
</StyledFullScreenEditorContainer>
</div>,
@@ -224,10 +236,6 @@ export const FormAdvancedTextFieldInput = ({
)
: null;
if (!isDefined(editor)) {
return null;
}
return (
<>
<StyledAdvancedTextFieldContainerWrapper
@@ -245,6 +253,7 @@ export const FormAdvancedTextFieldInput = ({
editor={editor}
readonly={readonly}
minHeight={editorMinHeight}
chrome={chrome}
/>
)}
@@ -263,7 +263,6 @@ export const RelationOneToManyFieldInput = () => {
createNewRecordAndOpenSidePanel,
createTargetRecord,
createJunctionRecord,
fieldName,
instanceId,
isMorphJunction,
isJunctionRelation,
@@ -3,6 +3,7 @@ import { SidePanelCommandMenuItemEditPage } from '@/command-menu-item/edit/compo
import { SidePanelNavigationMenuItemEditPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNavigationMenuItemEditPage';
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
import { SidePanelAiChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage';
import { SidePanelEmailBlockSettingsPage } from '@/side-panel/pages/email-block-settings/components/SidePanelEmailBlockSettingsPage';
import { SidePanelAskAiPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAiPage';
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
import { SidePanelSendCampaignTestPage } from '@/side-panel/pages/send-campaign-test/components/SidePanelSendCampaignTestPage';
@@ -88,5 +89,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
[SidePanelPages.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
[SidePanelPages.SendCampaignTest, <SidePanelSendCampaignTestPage />],
[SidePanelPages.EmailBlockSettings, <SidePanelEmailBlockSettingsPage />],
],
);
@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { t } from '@lingui/core/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { IconAdjustments } from 'twenty-ui/icon';
import { v4 } from 'uuid';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
export const useOpenEmailBlockSettingsInSidePanel = () => {
const { navigateSidePanelMenu } = useSidePanelMenu();
const openEmailBlockSettingsInSidePanel = useCallback(() => {
navigateSidePanelMenu({
page: SidePanelPages.EmailBlockSettings,
pageTitle: t`Block Settings`,
pageIcon: IconAdjustments,
pageId: v4(),
});
}, [navigateSidePanelMenu]);
return { openEmailBlockSettingsInSidePanel };
};
@@ -0,0 +1,72 @@
import { styled } from '@linaria/react';
import { IconAlignCenter, IconAlignLeft, IconAlignRight } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
const StyledAlignRow = styled.div`
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledAlignButton = styled.button<{ isActive: boolean }>`
align-items: center;
background: ${({ isActive }) =>
isActive ? themeCssVariables.background.transparent.medium : 'none'};
border: 1px solid
${({ isActive }) =>
isActive ? themeCssVariables.border.color.strong : 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ isActive }) =>
isActive
? themeCssVariables.font.color.primary
: themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
height: 28px;
justify-content: center;
padding: 0;
width: 32px;
&:hover {
background: ${themeCssVariables.background.transparent.light};
}
`;
export const CAMPAIGN_ALIGN_OPTIONS = [
{ align: 'left', Icon: IconAlignLeft },
{ align: 'center', Icon: IconAlignCenter },
{ align: 'right', Icon: IconAlignRight },
] as const;
type EmailAlignmentInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
export const EmailAlignmentInput = ({
label,
value,
onChange,
}: EmailAlignmentInputProps) => (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<StyledAlignRow>
{CAMPAIGN_ALIGN_OPTIONS.map(({ align, Icon }) => (
<StyledAlignButton
key={align}
type="button"
aria-label={align}
aria-pressed={value === align}
title={align}
isActive={value === align}
onClick={() => onChange(align)}
>
<Icon size={16} />
</StyledAlignButton>
))}
</StyledAlignRow>
</div>
);
@@ -0,0 +1,87 @@
import { useLingui } from '@lingui/react/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EmailAlignmentInput } from '@/side-panel/pages/email-block-settings/components/EmailAlignmentInput';
import { EmailColorInput } from '@/side-panel/pages/email-block-settings/components/EmailColorInput';
import { EmailSizeInput } from '@/side-panel/pages/email-block-settings/components/EmailSizeInput';
import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
export type EmailStyleFieldKind =
| 'text'
| 'color'
| 'box'
| 'size'
| 'alignment'
| 'textarea';
type EmailBlockSettingsFieldInputProps = {
field: {
label: MessageDescriptor;
input: EmailStyleFieldKind;
placeholder?: string;
};
value: string;
onChange: (value: string) => void;
};
export const EmailBlockSettingsFieldInput = ({
field,
value,
onChange,
}: EmailBlockSettingsFieldInputProps) => {
const { i18n } = useLingui();
const label = i18n._(field.label);
switch (field.input) {
case 'color':
return (
<EmailColorInput
label={label}
value={value}
onChange={onChange}
placeholder={field.placeholder}
/>
);
case 'size':
return (
<EmailSizeInput
label={label}
value={value}
onChange={onChange}
placeholder={field.placeholder}
/>
);
case 'alignment':
return (
<EmailAlignmentInput label={label} value={value} onChange={onChange} />
);
case 'textarea':
return (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<TextArea
textAreaId={`email-block-settings-${label}`}
value={value}
onChange={onChange}
placeholder={field.placeholder ?? ''}
minRows={6}
maxRows={16}
/>
</div>
);
default:
return (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<TextInput
value={value}
onChange={onChange}
placeholder={field.placeholder ?? ''}
fullWidth
/>
</div>
);
}
};
@@ -0,0 +1,151 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { IconFrame, IconSquare } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
& > :first-child {
flex: 1;
}
`;
const StyledSidesGrid = styled.div`
display: grid;
gap: ${themeCssVariables.spacing[1]};
grid-template-columns: repeat(4, 1fr);
margin-top: ${themeCssVariables.spacing[1]};
`;
const StyledUnitChip = styled.div`
align-items: center;
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${themeCssVariables.font.color.tertiary};
display: flex;
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.sm};
height: 32px;
padding: 0 ${themeCssVariables.spacing[2]};
`;
const SIDE_KEYS = ['top', 'right', 'bottom', 'left'] as const;
const SIDE_PLACEHOLDERS: Record<(typeof SIDE_KEYS)[number], string> = {
top: 'T',
right: 'R',
bottom: 'B',
left: 'L',
};
const toDisplayAmount = (token: string): string =>
token.endsWith('px') && !Number.isNaN(Number(token.slice(0, -2)))
? token.slice(0, -2)
: token;
const toCssToken = (input: string): string => {
const trimmed = input.trim();
if (trimmed === '') {
return '0px';
}
return Number.isNaN(Number(trimmed)) ? trimmed : `${trimmed}px`;
};
const areAllSidesEqual = ({ top, right, bottom, left }: CssBoxSides) =>
top === right && right === bottom && bottom === left;
export type CssBoxSides = {
top: string;
right: string;
bottom: string;
left: string;
};
type EmailBoxSidesInputProps = {
label: string;
sides: CssBoxSides;
onChange: (sides: CssBoxSides) => void;
placeholder?: string;
};
export const EmailBoxSidesInput = ({
label,
sides,
onChange,
placeholder,
}: EmailBoxSidesInputProps) => {
const { t } = useLingui();
const [isPerSide, setIsPerSide] = useState(!areAllSidesEqual(sides));
const commitAllSides = (input: string) => {
const token = input.trim() === '' ? '' : toCssToken(input);
onChange({ top: token, right: token, bottom: token, left: token });
};
const commitSide = (side: (typeof SIDE_KEYS)[number], input: string) => {
onChange({
...sides,
[side]: input.trim() === '' ? '' : toCssToken(input),
});
};
return (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<StyledRow>
{isPerSide ? (
<StyledSidesGrid>
{SIDE_KEYS.map((side) => (
<TextInput
key={side}
value={toDisplayAmount(sides[side])}
onChange={(input) => commitSide(side, input)}
placeholder={SIDE_PLACEHOLDERS[side]}
fullWidth
/>
))}
</StyledSidesGrid>
) : (
<TextInput
value={toDisplayAmount(sides.top)}
onChange={commitAllSides}
placeholder={placeholder ?? '0'}
fullWidth
/>
)}
<StyledUnitChip>px</StyledUnitChip>
<LightIconButton
Icon={IconSquare}
size="small"
accent={isPerSide ? 'tertiary' : 'secondary'}
title={t`Same value on every side`}
aria-pressed={!isPerSide}
onClick={() => {
setIsPerSide(false);
commitAllSides(toDisplayAmount(sides.top));
}}
/>
<LightIconButton
Icon={IconFrame}
size="small"
accent={isPerSide ? 'secondary' : 'tertiary'}
title={t`Edit each side`}
aria-pressed={isPerSide}
onClick={() => setIsPerSide(true)}
/>
</StyledRow>
</div>
);
};
@@ -0,0 +1,74 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
& > :last-child {
flex: 1;
}
`;
const StyledColorSwatchInput = styled.input`
background: none;
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
cursor: pointer;
flex-shrink: 0;
height: 32px;
padding: 2px;
width: 32px;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: ${themeCssVariables.border.radius.xs};
}
`;
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
// oxlint-disable-next-line twenty/no-hardcoded-colors
const COLOR_SWATCH_FALLBACK = '#ffffff';
type EmailColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export const EmailColorInput = ({
label,
value,
onChange,
placeholder,
}: EmailColorInputProps) => {
return (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<StyledRow>
<StyledColorSwatchInput
type="color"
value={HEX_COLOR_PATTERN.test(value) ? value : COLOR_SWATCH_FALLBACK}
onChange={(event) => onChange(event.target.value)}
/>
<TextInput
value={value}
onChange={onChange}
placeholder={placeholder ?? COLOR_SWATCH_FALLBACK}
fullWidth
/>
</StyledRow>
</div>
);
};
@@ -0,0 +1,152 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import {
type CanvasTheme,
isDefined,
resolveCanvasTheme,
} from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EmailBlockSettingsFieldInput } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
import {
EmailBoxSidesInput,
type CssBoxSides,
} from '@/side-panel/pages/email-block-settings/components/EmailBoxSidesInput';
import { EMAIL_BODY_THEME_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailBodyThemeFields';
import { EMAIL_PAGE_THEME_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailPageThemeFields';
import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[3]};
padding: ${themeCssVariables.spacing[4]};
`;
const StyledGroupTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
&:not(:first-child) {
margin-top: ${themeCssVariables.spacing[3]};
}
`;
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
padding: ${themeCssVariables.spacing[4]};
`;
const themeBoxValueToSides = (value: string): CssBoxSides => {
const tokens = value.trim().split(/\s+/);
if (tokens.length === 4) {
return {
top: tokens[0],
right: tokens[1],
bottom: tokens[2],
left: tokens[3],
};
}
if (tokens.length === 3) {
return {
top: tokens[0],
right: tokens[1],
bottom: tokens[2],
left: tokens[1],
};
}
if (tokens.length === 2) {
return {
top: tokens[0],
right: tokens[1],
bottom: tokens[0],
left: tokens[1],
};
}
return { top: value, right: value, bottom: value, left: value };
};
const sidesToThemeBoxValue = ({ top, right, bottom, left }: CssBoxSides) =>
top === right && right === bottom && bottom === left
? top
: `${top} ${right} ${bottom} ${left}`;
type EmailPageStyleSectionProps = {
editor: Editor;
};
export const EmailPageStyleSection = ({
editor,
}: EmailPageStyleSectionProps) => {
const { t, i18n } = useLingui();
const canvasTheme = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
resolveCanvasTheme(currentEditor.state.doc.attrs.canvasTheme),
});
if (!isDefined(canvasTheme)) {
return (
<StyledHint>
{t`Select a section, columns, button or divider in the email body to edit its settings.`}
</StyledHint>
);
}
const setThemeValue = (themeKey: keyof CanvasTheme, value: string) => {
editor
.chain()
.command(({ tr }) => {
tr.setDocAttribute('canvasTheme', {
...canvasTheme,
[themeKey]: value,
});
return true;
})
.run();
};
const renderThemeField = (field: EmailThemeField) => {
if (field.input === 'box') {
return (
<EmailBoxSidesInput
key={field.property}
label={i18n._(field.label)}
sides={themeBoxValueToSides(canvasTheme[field.property])}
onChange={(sides) =>
setThemeValue(field.property, sidesToThemeBoxValue(sides))
}
placeholder={field.placeholder}
/>
);
}
return (
<EmailBlockSettingsFieldInput
key={field.property}
field={field}
value={canvasTheme[field.property]}
onChange={(value) => setThemeValue(field.property, value)}
/>
);
};
return (
<StyledContainer>
<StyledGroupTitle>{t`Page style`}</StyledGroupTitle>
{EMAIL_PAGE_THEME_FIELDS.map(renderThemeField)}
<StyledGroupTitle>{t`Body`}</StyledGroupTitle>
{EMAIL_BODY_THEME_FIELDS.map(renderThemeField)}
</StyledContainer>
);
};
@@ -0,0 +1,87 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { parseCssSizeValue } from '@/advanced-text-editor/utils/parseCssSizeValue';
import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
import { TextInput } from '@/ui/input/components/TextInput';
const StyledRow = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
& > :first-child {
flex: 1;
}
`;
const StyledUnitSelect = styled.select`
background: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
cursor: pointer;
font-family: inherit;
font-size: ${themeCssVariables.font.size.sm};
height: 32px;
padding: 0 ${themeCssVariables.spacing[1]};
`;
const SIZE_UNITS = ['px', '%', 'em'] as const;
type EmailSizeInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export const EmailSizeInput = ({
label,
value,
onChange,
placeholder,
}: EmailSizeInputProps) => {
const { amount, unit } = parseCssSizeValue(value);
const displayedAmount = amount === '' ? value.trim() : amount;
const commit = (nextAmount: string, nextUnit: string) => {
const trimmedAmount = nextAmount.trim();
if (trimmedAmount === '') {
onChange('');
return;
}
if (!/^-?(\d+|\d*\.\d+)$/.test(trimmedAmount)) {
onChange(trimmedAmount);
return;
}
onChange(`${trimmedAmount}${nextUnit}`);
};
return (
<div>
<StyledEmailFieldLabel>{label}</StyledEmailFieldLabel>
<StyledRow>
<TextInput
value={displayedAmount}
onChange={(nextAmount) => commit(nextAmount, unit)}
placeholder={placeholder ?? '0'}
fullWidth
/>
<StyledUnitSelect
value={unit}
onChange={(event) => commit(displayedAmount, event.target.value)}
>
{SIZE_UNITS.map((sizeUnit) => (
<option key={sizeUnit} value={sizeUnit}>
{sizeUnit}
</option>
))}
</StyledUnitSelect>
</StyledRow>
</div>
);
};
@@ -0,0 +1,245 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import {
isDefined,
resolveCanvasTheme,
TIPTAP_NODE_TYPES,
} from 'twenty-shared/utils';
import { IconTrash } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { activeEmailEditorState } from '@/advanced-text-editor/states/activeEmailEditorState';
import { getBlockSelectionTarget } from '@/advanced-text-editor/utils/getBlockSelectionTarget';
import { getBlockStyle } from '@/advanced-text-editor/utils/getBlockStyle';
import { EmailBlockSettingsFieldInput } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
import {
EmailBoxSidesInput,
type CssBoxSides,
} from '@/side-panel/pages/email-block-settings/components/EmailBoxSidesInput';
import { EmailPageStyleSection } from '@/side-panel/pages/email-block-settings/components/EmailPageStyleSection';
import { EMAIL_BLOCK_SETTINGS_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailBlockSettingsFields';
import { getEmailBlockLabel } from '@/side-panel/pages/email-block-settings/utils/getEmailBlockLabel';
import { getEffectiveSectionStyleValue } from '@/side-panel/pages/email-block-settings/utils/getEffectiveSectionStyleValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[4]};
`;
const StyledHint = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
padding: ${themeCssVariables.spacing[4]};
`;
const StyledBlockHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledBlockTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const BORDER_STYLE_COMPANIONS: Record<string, string> = {
borderWidth: 'borderStyle',
borderTopWidth: 'borderTopStyle',
};
const BOX_FIELD_SIDE_PROPERTIES: Record<
string,
[string, string, string, string]
> = {
padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
borderRadius: [
'borderTopLeftRadius',
'borderTopRightRadius',
'borderBottomRightRadius',
'borderBottomLeftRadius',
],
};
const EmailBlockSettingsContent = ({ editor }: { editor: Editor }) => {
const { i18n, t } = useLingui();
const target = useEditorState({
editor,
selector: ({ editor: currentEditor }) =>
getBlockSelectionTarget(currentEditor),
});
if (!isDefined(target)) {
return <EmailPageStyleSection editor={editor} />;
}
const fields = EMAIL_BLOCK_SETTINGS_FIELDS[target.nodeType] ?? [];
const styles = getBlockStyle(target.attrs.style);
const canvasTheme = resolveCanvasTheme(editor.state.doc.attrs.canvasTheme);
const displayedStyleValue = (property: string) =>
styles[property] ??
(target.nodeType === TIPTAP_NODE_TYPES.SECTION
? getEffectiveSectionStyleValue(property, canvasTheme)
: '');
const updateTargetAttributes = (attrs: Record<string, unknown>) => {
editor
.chain()
.command(({ tr }) => {
const node = tr.doc.nodeAt(target.pos);
if (!isDefined(node)) {
return false;
}
tr.setNodeMarkup(target.pos, undefined, { ...node.attrs, ...attrs });
return true;
})
.run();
};
const handleRemoveBlock = () => {
editor
.chain()
.focus()
.command(({ tr }) => {
const node = tr.doc.nodeAt(target.pos);
if (!isDefined(node)) {
return false;
}
tr.delete(target.pos, target.pos + node.nodeSize);
return true;
})
.run();
};
const handleFieldChange = (field: (typeof fields)[number], value: string) => {
if (field.kind === 'attribute') {
if (field.property === 'width') {
const trimmed = value.trim();
if (trimmed === '' || trimmed === 'auto') {
updateTargetAttributes({ width: null });
} else if (Number.isFinite(Number(trimmed)) && Number(trimmed) >= 0) {
updateTargetAttributes({ width: Number(trimmed) });
}
return;
}
updateTargetAttributes({ [field.property]: value });
return;
}
const nextStyles = { ...styles };
if (value.trim() === '') {
delete nextStyles[field.property];
} else {
nextStyles[field.property] = value;
}
const borderStyleProperty = BORDER_STYLE_COMPANIONS[field.property];
if (isDefined(borderStyleProperty)) {
if (value.trim() === '' || value.trim() === '0px') {
delete nextStyles[borderStyleProperty];
} else {
nextStyles[borderStyleProperty] ??= 'solid';
}
}
updateTargetAttributes({ style: nextStyles });
};
const handleBoxFieldChange = (
sideProperties: [string, string, string, string],
sides: CssBoxSides,
) => {
const nextStyles = { ...styles };
const sideValues = [sides.top, sides.right, sides.bottom, sides.left];
sideProperties.forEach((property, index) => {
if (sideValues[index].trim() === '') {
delete nextStyles[property];
} else {
nextStyles[property] = sideValues[index];
}
});
updateTargetAttributes({ style: nextStyles });
};
return (
<StyledContainer>
<StyledBlockHeader>
<StyledBlockTitle>
{getEmailBlockLabel(target.nodeType)}
</StyledBlockTitle>
<LightIconButton
Icon={IconTrash}
size="small"
accent="tertiary"
title={t`Remove block`}
onClick={handleRemoveBlock}
/>
</StyledBlockHeader>
{fields.map((field) => {
const key = `${target.nodeType}-${target.pos}-${field.property}`;
const sideProperties = BOX_FIELD_SIDE_PROPERTIES[field.property];
if (
field.kind === 'style' &&
field.input === 'box' &&
isDefined(sideProperties)
) {
return (
<EmailBoxSidesInput
key={key}
label={i18n._(field.label)}
sides={{
top: displayedStyleValue(sideProperties[0]),
right: displayedStyleValue(sideProperties[1]),
bottom: displayedStyleValue(sideProperties[2]),
left: displayedStyleValue(sideProperties[3]),
}}
onChange={(sides) => handleBoxFieldChange(sideProperties, sides)}
placeholder={field.placeholder}
/>
);
}
return (
<EmailBlockSettingsFieldInput
key={key}
field={field}
value={
field.kind === 'attribute'
? String(target.attrs[field.property] ?? '')
: displayedStyleValue(field.property)
}
onChange={(value) => handleFieldChange(field, value)}
/>
);
})}
</StyledContainer>
);
};
export const SidePanelEmailBlockSettingsPage = () => {
const { t } = useLingui();
const activeEmailEditor = useAtomStateValue(activeEmailEditorState);
if (!isDefined(activeEmailEditor) || activeEmailEditor.isDestroyed) {
return (
<StyledHint>{t`Open an email editor to edit block settings.`}</StyledHint>
);
}
return <EmailBlockSettingsContent editor={activeEmailEditor} />;
};
@@ -0,0 +1,9 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledEmailFieldLabel = styled.div`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin-bottom: ${themeCssVariables.spacing[1]};
`;
@@ -0,0 +1,340 @@
/* oxlint-disable twenty/no-hardcoded-colors --
placeholders show literal inline CSS examples for email content, where
theme variables do not exist */
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
import { type EmailStyleFieldKind } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
export type EmailBlockSettingsField = {
label: MessageDescriptor;
kind: 'style' | 'attribute';
property: string;
input: EmailStyleFieldKind;
placeholder?: string;
};
export const EMAIL_BLOCK_SETTINGS_FIELDS: Record<
string,
EmailBlockSettingsField[]
> = {
[TIPTAP_NODE_TYPES.SECTION]: [
{
label: msg`Text color`,
kind: 'style',
property: 'color',
input: 'color',
},
{
label: msg`Font size`,
kind: 'style',
property: 'fontSize',
input: 'size',
placeholder: '14',
},
{
label: msg`Line height`,
kind: 'style',
property: 'lineHeight',
input: 'text',
placeholder: '1.5',
},
{
label: msg`Letter spacing`,
kind: 'style',
property: 'letterSpacing',
input: 'size',
placeholder: '0',
},
{
label: msg`Alignment`,
kind: 'style',
property: 'textAlign',
input: 'alignment',
},
{
label: msg`Background`,
kind: 'style',
property: 'backgroundColor',
input: 'color',
},
{
label: msg`Padding`,
kind: 'style',
property: 'padding',
input: 'box',
placeholder: '12',
},
{
label: msg`Corner radius`,
kind: 'style',
property: 'borderRadius',
input: 'box',
placeholder: '8',
},
{
label: msg`Border`,
kind: 'style',
property: 'borderWidth',
input: 'size',
placeholder: '0',
},
{
label: msg`Border color`,
kind: 'style',
property: 'borderColor',
input: 'color',
},
],
[TIPTAP_NODE_TYPES.COLUMNS]: [
{
label: msg`Text color`,
kind: 'style',
property: 'color',
input: 'color',
},
{
label: msg`Font size`,
kind: 'style',
property: 'fontSize',
input: 'size',
placeholder: '14',
},
{
label: msg`Line height`,
kind: 'style',
property: 'lineHeight',
input: 'text',
placeholder: '1.5',
},
{
label: msg`Letter spacing`,
kind: 'style',
property: 'letterSpacing',
input: 'size',
placeholder: '0',
},
{
label: msg`Alignment`,
kind: 'style',
property: 'textAlign',
input: 'alignment',
},
{
label: msg`Background`,
kind: 'style',
property: 'backgroundColor',
input: 'color',
},
{
label: msg`Padding`,
kind: 'style',
property: 'padding',
input: 'box',
placeholder: '12',
},
{
label: msg`Corner radius`,
kind: 'style',
property: 'borderRadius',
input: 'box',
placeholder: '8',
},
{
label: msg`Border`,
kind: 'style',
property: 'borderWidth',
input: 'size',
placeholder: '0',
},
{
label: msg`Border color`,
kind: 'style',
property: 'borderColor',
input: 'color',
},
],
[TIPTAP_NODE_TYPES.COLUMN]: [
{
label: msg`Text color`,
kind: 'style',
property: 'color',
input: 'color',
},
{
label: msg`Font size`,
kind: 'style',
property: 'fontSize',
input: 'size',
placeholder: '14',
},
{
label: msg`Line height`,
kind: 'style',
property: 'lineHeight',
input: 'text',
placeholder: '1.5',
},
{
label: msg`Letter spacing`,
kind: 'style',
property: 'letterSpacing',
input: 'size',
placeholder: '0',
},
{
label: msg`Alignment`,
kind: 'style',
property: 'textAlign',
input: 'alignment',
},
{
label: msg`Width`,
kind: 'style',
property: 'width',
input: 'size',
placeholder: '50%',
},
{
label: msg`Background`,
kind: 'style',
property: 'backgroundColor',
input: 'color',
},
{
label: msg`Padding`,
kind: 'style',
property: 'padding',
input: 'box',
placeholder: '12',
},
{
label: msg`Corner radius`,
kind: 'style',
property: 'borderRadius',
input: 'box',
placeholder: '8',
},
{
label: msg`Border`,
kind: 'style',
property: 'borderWidth',
input: 'size',
placeholder: '0',
},
{
label: msg`Border color`,
kind: 'style',
property: 'borderColor',
input: 'color',
},
],
[TIPTAP_NODE_TYPES.BUTTON]: [
{
label: msg`Alignment`,
kind: 'attribute',
property: 'align',
input: 'alignment',
},
{
label: msg`Link URL`,
kind: 'attribute',
property: 'href',
input: 'text',
placeholder: 'https://',
},
{
label: msg`Background`,
kind: 'style',
property: 'backgroundColor',
input: 'color',
},
{
label: msg`Text color`,
kind: 'style',
property: 'color',
input: 'color',
},
{
label: msg`Padding`,
kind: 'style',
property: 'padding',
input: 'box',
placeholder: '10',
},
{
label: msg`Corner radius`,
kind: 'style',
property: 'borderRadius',
input: 'box',
placeholder: '6',
},
],
[TIPTAP_NODE_TYPES.IMAGE]: [
{
label: msg`Alignment`,
kind: 'attribute',
property: 'align',
input: 'alignment',
},
{
label: msg`Link URL`,
kind: 'attribute',
property: 'href',
input: 'text',
placeholder: 'https://',
},
{
label: msg`Source`,
kind: 'attribute',
property: 'src',
input: 'text',
placeholder: 'https://',
},
{
label: msg`Alt text`,
kind: 'attribute',
property: 'alt',
input: 'text',
},
{
label: msg`Width`,
kind: 'attribute',
property: 'width',
input: 'text',
placeholder: 'auto',
},
],
[TIPTAP_NODE_TYPES.HTML]: [
{
label: msg`HTML`,
kind: 'attribute',
property: 'html',
input: 'textarea',
placeholder: '<p>Hello</p>',
},
],
[TIPTAP_NODE_TYPES.DIVIDER]: [
{
label: msg`Thickness`,
kind: 'style',
property: 'borderTopWidth',
input: 'size',
placeholder: '1',
},
{
label: msg`Color`,
kind: 'style',
property: 'borderTopColor',
input: 'color',
},
{
label: msg`Margin`,
kind: 'style',
property: 'margin',
input: 'box',
placeholder: '16',
},
],
};
@@ -0,0 +1,24 @@
import { msg } from '@lingui/core/macro';
import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
export const EMAIL_BODY_THEME_FIELDS: EmailThemeField[] = [
{ label: msg`Alignment`, property: 'textAlign', input: 'alignment' },
{ label: msg`Text`, property: 'textColor', input: 'color' },
{ label: msg`Background`, property: 'bodyBackground', input: 'color' },
{ label: msg`Width`, property: 'width', input: 'size', placeholder: '600' },
{ label: msg`Padding`, property: 'padding', input: 'box', placeholder: '24' },
{
label: msg`Corner radius`,
property: 'cornerRadius',
input: 'box',
placeholder: '8',
},
{
label: msg`Border`,
property: 'borderWidth',
input: 'size',
placeholder: '0',
},
{ label: msg`Border color`, property: 'borderColor', input: 'color' },
];
@@ -0,0 +1,13 @@
import { msg } from '@lingui/core/macro';
import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
export const EMAIL_PAGE_THEME_FIELDS: EmailThemeField[] = [
{ label: msg`Background`, property: 'pageBackground', input: 'color' },
{
label: msg`Padding`,
property: 'pagePadding',
input: 'box',
placeholder: '24',
},
];
@@ -0,0 +1,11 @@
import { type MessageDescriptor } from '@lingui/core';
import { type CanvasTheme } from 'twenty-shared/utils';
import { type EmailStyleFieldKind } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
export type EmailThemeField = {
label: MessageDescriptor;
property: keyof CanvasTheme;
input: EmailStyleFieldKind;
placeholder?: string;
};
@@ -0,0 +1,23 @@
import { type CanvasTheme } from 'twenty-shared/utils';
export const getEffectiveSectionStyleValue = (
property: string,
canvasTheme: CanvasTheme | null,
): string => {
switch (property) {
case 'color':
return canvasTheme?.textColor ?? '';
case 'textAlign':
return canvasTheme?.textAlign ?? 'left';
case 'backgroundColor':
return canvasTheme?.bodyBackground ?? '';
case 'fontSize':
return '14px';
case 'lineHeight':
return '1.5';
case 'borderColor':
return canvasTheme?.borderColor ?? '';
default:
return '0px';
}
};
@@ -0,0 +1,23 @@
import { t } from '@lingui/core/macro';
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
export const getEmailBlockLabel = (nodeType: string): string => {
switch (nodeType) {
case TIPTAP_NODE_TYPES.SECTION:
return t`Section`;
case TIPTAP_NODE_TYPES.COLUMNS:
return t`Columns`;
case TIPTAP_NODE_TYPES.COLUMN:
return t`Column`;
case TIPTAP_NODE_TYPES.BUTTON:
return t`Button`;
case TIPTAP_NODE_TYPES.DIVIDER:
return t`Divider`;
case TIPTAP_NODE_TYPES.HTML:
return t`HTML`;
case TIPTAP_NODE_TYPES.IMAGE:
return t`Image`;
default:
return nodeType;
}
};
@@ -2,7 +2,6 @@ import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { flowComponentState } from '@/workflow/states/flowComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep';
import { useResetWorkflowAiAgentPermissionsStateOnSidePanelClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnSidePanelClose';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
@@ -17,9 +16,6 @@ export const useDeleteStep = () => {
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { closeSidePanelMenu } = useSidePanelMenu();
const workflowVisualizerWorkflowId = useAtomComponentStateValue(
workflowVisualizerWorkflowIdComponentState,
);
const flow = useAtomComponentStateValue(flowComponentState);
const deleteStep = async (stepId: string) => {