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:
@@ -188,6 +188,7 @@ enum EngineComponentKey {
|
|||||||
COMPOSE_CAMPAIGN
|
COMPOSE_CAMPAIGN
|
||||||
SEND_MESSAGE_CAMPAIGN
|
SEND_MESSAGE_CAMPAIGN
|
||||||
SEND_MESSAGE_CAMPAIGN_TEST
|
SEND_MESSAGE_CAMPAIGN_TEST
|
||||||
|
EMAIL_BLOCK_SETTINGS
|
||||||
GO_TO_PEOPLE
|
GO_TO_PEOPLE
|
||||||
GO_TO_COMPANIES
|
GO_TO_COMPANIES
|
||||||
GO_TO_DASHBOARDS
|
GO_TO_DASHBOARDS
|
||||||
@@ -3726,6 +3727,7 @@ enum FileFolder {
|
|||||||
Dependencies
|
Dependencies
|
||||||
Workflow
|
Workflow
|
||||||
EmailAttachment
|
EmailAttachment
|
||||||
|
EmailImage
|
||||||
AppTarball
|
AppTarball
|
||||||
GeneratedSdkClient
|
GeneratedSdkClient
|
||||||
Dpa
|
Dpa
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export interface CommandMenuItem {
|
|||||||
__typename: 'CommandMenuItem'
|
__typename: 'CommandMenuItem'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'RETRY_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'COMPOSE_CAMPAIGN' | 'SEND_MESSAGE_CAMPAIGN' | 'SEND_MESSAGE_CAMPAIGN_TEST' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
|
export type EngineComponentKey = 'NAVIGATE_TO_NEXT_RECORD' | 'NAVIGATE_TO_PREVIOUS_RECORD' | 'CREATE_NEW_RECORD' | 'DELETE_RECORDS' | 'RESTORE_RECORDS' | 'DESTROY_RECORDS' | 'ADD_TO_FAVORITES' | 'REMOVE_FROM_FAVORITES' | 'EXPORT_NOTE_TO_PDF' | 'EXPORT_RECORDS' | 'UPDATE_MULTIPLE_RECORDS' | 'MERGE_MULTIPLE_RECORDS' | 'IMPORT_RECORDS' | 'EXPORT_VIEW' | 'SEE_DELETED_RECORDS' | 'CREATE_NEW_VIEW' | 'HIDE_DELETED_RECORDS' | 'EDIT_RECORD_PAGE_LAYOUT' | 'EDIT_DASHBOARD_LAYOUT' | 'SAVE_DASHBOARD_LAYOUT' | 'CANCEL_DASHBOARD_LAYOUT' | 'DUPLICATE_DASHBOARD' | 'ACTIVATE_WORKFLOW' | 'DEACTIVATE_WORKFLOW' | 'DISCARD_DRAFT_WORKFLOW' | 'TEST_WORKFLOW' | 'SEE_ACTIVE_VERSION_WORKFLOW' | 'SEE_RUNS_WORKFLOW' | 'SEE_VERSIONS_WORKFLOW' | 'ADD_NODE_WORKFLOW' | 'TIDY_UP_WORKFLOW' | 'DUPLICATE_WORKFLOW' | 'SEE_VERSION_WORKFLOW_RUN' | 'SEE_WORKFLOW_WORKFLOW_RUN' | 'STOP_WORKFLOW_RUN' | 'RETRY_WORKFLOW_RUN' | 'SEE_RUNS_WORKFLOW_VERSION' | 'SEE_WORKFLOW_WORKFLOW_VERSION' | 'USE_AS_DRAFT_WORKFLOW_VERSION' | 'SEE_VERSIONS_WORKFLOW_VERSION' | 'SEARCH_RECORDS' | 'SEARCH_RECORDS_FALLBACK' | 'ASK_AI' | 'VIEW_PREVIOUS_AI_CHATS' | 'NAVIGATION' | 'TRIGGER_WORKFLOW_VERSION' | 'FRONT_COMPONENT_RENDERER' | 'REPLY_TO_EMAIL_THREAD' | 'COMPOSE_EMAIL' | 'COMPOSE_CAMPAIGN' | 'SEND_MESSAGE_CAMPAIGN' | 'SEND_MESSAGE_CAMPAIGN_TEST' | 'EMAIL_BLOCK_SETTINGS' | 'GO_TO_PEOPLE' | 'GO_TO_COMPANIES' | 'GO_TO_DASHBOARDS' | 'GO_TO_OPPORTUNITIES' | 'GO_TO_SETTINGS' | 'GO_TO_TASKS' | 'GO_TO_NOTES' | 'GO_TO_WORKFLOWS' | 'GO_TO_RUNS' | 'DELETE_SINGLE_RECORD' | 'DELETE_MULTIPLE_RECORDS' | 'RESTORE_SINGLE_RECORD' | 'RESTORE_MULTIPLE_RECORDS' | 'DESTROY_SINGLE_RECORD' | 'DESTROY_MULTIPLE_RECORDS' | 'EXPORT_FROM_RECORD_INDEX' | 'EXPORT_FROM_RECORD_SHOW' | 'EXPORT_MULTIPLE_RECORDS'
|
||||||
|
|
||||||
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
|
export type CommandMenuItemAvailabilityType = 'GLOBAL' | 'GLOBAL_OBJECT_CONTEXT' | 'RECORD_SELECTION' | 'FALLBACK'
|
||||||
|
|
||||||
@@ -3187,7 +3187,7 @@ export interface Mutation {
|
|||||||
__typename: 'Mutation'
|
__typename: 'Mutation'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
|
export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'EmailImage' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
|
||||||
|
|
||||||
export type RunAgentMessageRole = 'user' | 'assistant'
|
export type RunAgentMessageRole = 'user' | 'assistant'
|
||||||
|
|
||||||
@@ -9184,6 +9184,7 @@ export const enumEngineComponentKey = {
|
|||||||
COMPOSE_CAMPAIGN: 'COMPOSE_CAMPAIGN' as const,
|
COMPOSE_CAMPAIGN: 'COMPOSE_CAMPAIGN' as const,
|
||||||
SEND_MESSAGE_CAMPAIGN: 'SEND_MESSAGE_CAMPAIGN' as const,
|
SEND_MESSAGE_CAMPAIGN: 'SEND_MESSAGE_CAMPAIGN' as const,
|
||||||
SEND_MESSAGE_CAMPAIGN_TEST: 'SEND_MESSAGE_CAMPAIGN_TEST' as const,
|
SEND_MESSAGE_CAMPAIGN_TEST: 'SEND_MESSAGE_CAMPAIGN_TEST' as const,
|
||||||
|
EMAIL_BLOCK_SETTINGS: 'EMAIL_BLOCK_SETTINGS' as const,
|
||||||
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
|
GO_TO_PEOPLE: 'GO_TO_PEOPLE' as const,
|
||||||
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
|
GO_TO_COMPANIES: 'GO_TO_COMPANIES' as const,
|
||||||
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
|
GO_TO_DASHBOARDS: 'GO_TO_DASHBOARDS' as const,
|
||||||
@@ -9860,6 +9861,7 @@ export const enumFileFolder = {
|
|||||||
Dependencies: 'Dependencies' as const,
|
Dependencies: 'Dependencies' as const,
|
||||||
Workflow: 'Workflow' as const,
|
Workflow: 'Workflow' as const,
|
||||||
EmailAttachment: 'EmailAttachment' as const,
|
EmailAttachment: 'EmailAttachment' as const,
|
||||||
|
EmailImage: 'EmailImage' as const,
|
||||||
AppTarball: 'AppTarball' as const,
|
AppTarball: 'AppTarball' as const,
|
||||||
GeneratedSdkClient: 'GeneratedSdkClient' as const,
|
GeneratedSdkClient: 'GeneratedSdkClient' as const,
|
||||||
Dpa: 'Dpa' as const
|
Dpa: 'Dpa' as const
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const colors = {
|
|||||||
blue40: '#5e90f2',
|
blue40: '#5e90f2',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emailTheme = {
|
export const canvasTheme = {
|
||||||
font: {
|
font: {
|
||||||
colors: {
|
colors: {
|
||||||
highlighted: grayScale.gray60,
|
highlighted: grayScale.gray60,
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Font, Head } from 'react-email';
|
import { Font, Head } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
export const BaseHead = () => {
|
export const BaseHead = () => {
|
||||||
return (
|
return (
|
||||||
<Head>
|
<Head>
|
||||||
<title>Twenty email</title>
|
<title>Twenty email</title>
|
||||||
<Font
|
<Font
|
||||||
fontFamily={emailTheme.font.family}
|
fontFamily={canvasTheme.font.family}
|
||||||
fallbackFontFamily="sans-serif"
|
fallbackFontFamily="sans-serif"
|
||||||
fontStyle="normal"
|
fontStyle="normal"
|
||||||
fontWeight={emailTheme.font.weight.regular}
|
fontWeight={canvasTheme.font.weight.regular}
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Button } from 'react-email';
|
import { Button } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
const callToActionStyle = {
|
const callToActionStyle = {
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
padding: '8px 32px',
|
padding: '8px 32px',
|
||||||
borderRadius: emailTheme.border.radius.md,
|
borderRadius: canvasTheme.border.radius.md,
|
||||||
border: `1px solid ${emailTheme.background.transparent.light}`,
|
border: `1px solid ${canvasTheme.background.transparent.light}`,
|
||||||
background: emailTheme.background.button,
|
background: canvasTheme.background.button,
|
||||||
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
|
boxShadow: `0px 2px 4px 0px ${canvasTheme.background.transparent.light}, 0px 0px 4px 0px ${canvasTheme.background.transparent.medium}`,
|
||||||
color: emailTheme.font.colors.inverted,
|
color: canvasTheme.font.colors.inverted,
|
||||||
fontSize: emailTheme.font.size.md,
|
fontSize: canvasTheme.font.size.md,
|
||||||
fontWeight: emailTheme.font.weight.bold,
|
fontWeight: canvasTheme.font.weight.bold,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Column, Container, Row } from 'react-email';
|
import { Column, Container, Row } from 'react-email';
|
||||||
import React, { type JSX } from 'react';
|
import React, { type JSX } from 'react';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
type HighlightedContainerProps = {
|
type HighlightedContainerProps = {
|
||||||
children: JSX.Element | JSX.Element[] | string;
|
children: JSX.Element | JSX.Element[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlightedContainerStyle = {
|
const highlightedContainerStyle = {
|
||||||
background: emailTheme.background.colors.highlight,
|
background: canvasTheme.background.colors.highlight,
|
||||||
border: `1px solid ${emailTheme.border.color.highlighted}`,
|
border: `1px solid ${canvasTheme.border.color.highlighted}`,
|
||||||
borderRadius: emailTheme.border.radius.md,
|
borderRadius: canvasTheme.border.radius.md,
|
||||||
padding: '24px 48px',
|
padding: '24px 48px',
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Text } from 'react-email';
|
import { Text } from 'react-email';
|
||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
const highlightedStyle = {
|
const highlightedStyle = {
|
||||||
borderRadius: emailTheme.border.radius.sm,
|
borderRadius: canvasTheme.border.radius.sm,
|
||||||
background: emailTheme.background.colors.highlight,
|
background: canvasTheme.background.colors.highlight,
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: emailTheme.font.size.lg,
|
fontSize: canvasTheme.font.size.lg,
|
||||||
fontWeight: emailTheme.font.weight.bold,
|
fontWeight: canvasTheme.font.weight.bold,
|
||||||
color: emailTheme.font.colors.highlighted,
|
color: canvasTheme.font.colors.highlighted,
|
||||||
};
|
};
|
||||||
|
|
||||||
type HighlightedTextProps = {
|
type HighlightedTextProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Link as EmailLink } from 'react-email';
|
import { Link as EmailLink } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
const linkStyle = {
|
const linkStyle = {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
@@ -19,7 +19,7 @@ export const Link = ({ value, href, color }: LinkProps) => {
|
|||||||
href={href}
|
href={href}
|
||||||
style={{
|
style={{
|
||||||
...linkStyle,
|
...linkStyle,
|
||||||
color: color ?? emailTheme.font.colors.tertiary,
|
color: color ?? canvasTheme.font.colors.tertiary,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Text } from 'react-email';
|
import { Text } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
type MainTextProps = {
|
type MainTextProps = {
|
||||||
children: JSX.Element | JSX.Element[] | string;
|
children: JSX.Element | JSX.Element[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mainTextStyle = {
|
const mainTextStyle = {
|
||||||
fontFamily: emailTheme.font.family,
|
fontFamily: canvasTheme.font.family,
|
||||||
fontSize: emailTheme.font.size.md,
|
fontSize: canvasTheme.font.size.md,
|
||||||
fontWeight: emailTheme.font.weight.regular,
|
fontWeight: canvasTheme.font.weight.regular,
|
||||||
color: emailTheme.font.colors.primary,
|
color: canvasTheme.font.colors.primary,
|
||||||
lineHeight: emailTheme.font.lineHeight,
|
lineHeight: canvasTheme.font.lineHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MainText = ({ children }: MainTextProps) => {
|
export const MainText = ({ children }: MainTextProps) => {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Text } from 'react-email';
|
import { Text } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
type ShadowTextProps = {
|
type ShadowTextProps = {
|
||||||
children: JSX.Element | JSX.Element[] | string;
|
children: JSX.Element | JSX.Element[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shadowTextStyle = {
|
const shadowTextStyle = {
|
||||||
fontSize: emailTheme.font.size.sm,
|
fontSize: canvasTheme.font.size.sm,
|
||||||
fontWeight: emailTheme.font.weight.regular,
|
fontWeight: canvasTheme.font.weight.regular,
|
||||||
color: emailTheme.font.colors.tertiary,
|
color: canvasTheme.font.colors.tertiary,
|
||||||
margin: '0 0 12px 0',
|
margin: '0 0 12px 0',
|
||||||
lineHeight: emailTheme.font.lineHeight,
|
lineHeight: canvasTheme.font.lineHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ShadowText = ({ children }: ShadowTextProps) => {
|
export const ShadowText = ({ children }: ShadowTextProps) => {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Heading } from 'react-email';
|
import { Heading } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
type SubTitleProps = {
|
type SubTitleProps = {
|
||||||
value: JSX.Element | JSX.Element[] | string;
|
value: JSX.Element | JSX.Element[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const subTitleStyle = {
|
const subTitleStyle = {
|
||||||
fontFamily: emailTheme.font.family,
|
fontFamily: canvasTheme.font.family,
|
||||||
fontSize: emailTheme.font.size.lg,
|
fontSize: canvasTheme.font.size.lg,
|
||||||
fontWeight: emailTheme.font.weight.bold,
|
fontWeight: canvasTheme.font.weight.bold,
|
||||||
color: emailTheme.font.colors.highlighted,
|
color: canvasTheme.font.colors.highlighted,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SubTitle = ({ value }: SubTitleProps) => {
|
export const SubTitle = ({ value }: SubTitleProps) => {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { type JSX } from 'react';
|
import { type JSX } from 'react';
|
||||||
import { Heading } from 'react-email';
|
import { Heading } from 'react-email';
|
||||||
|
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
type TitleProps = {
|
type TitleProps = {
|
||||||
value: JSX.Element | JSX.Element[] | string;
|
value: JSX.Element | JSX.Element[] | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleStyle = {
|
const titleStyle = {
|
||||||
fontFamily: emailTheme.font.family,
|
fontFamily: canvasTheme.font.family,
|
||||||
fontSize: emailTheme.font.size.xl,
|
fontSize: canvasTheme.font.size.xl,
|
||||||
fontWeight: emailTheme.font.weight.bold,
|
fontWeight: canvasTheme.font.weight.bold,
|
||||||
color: emailTheme.font.colors.highlighted,
|
color: canvasTheme.font.colors.highlighted,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Title = ({ value }: TitleProps) => {
|
export const Title = ({ value }: TitleProps) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trans } from '@lingui/react';
|
import { Trans } from '@lingui/react';
|
||||||
import { Img } from 'react-email';
|
import { Img } from 'react-email';
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
import { BaseEmail } from 'src/components/BaseEmail';
|
import { BaseEmail } from 'src/components/BaseEmail';
|
||||||
import { CallToAction } from 'src/components/CallToAction';
|
import { CallToAction } from 'src/components/CallToAction';
|
||||||
@@ -55,7 +55,7 @@ export const SendInviteLinkEmail = ({
|
|||||||
<Link
|
<Link
|
||||||
href={`mailto:${senderEmail}`}
|
href={`mailto:${senderEmail}`}
|
||||||
value={senderEmail}
|
value={senderEmail}
|
||||||
color={emailTheme.font.colors.blue}
|
color={canvasTheme.font.colors.blue}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
1: <b />,
|
1: <b />,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trans } from '@lingui/react';
|
import { Trans } from '@lingui/react';
|
||||||
import { Img } from 'react-email';
|
import { Img } from 'react-email';
|
||||||
import { emailTheme } from 'src/common-style';
|
import { canvasTheme } from 'src/common-style';
|
||||||
|
|
||||||
import { BaseEmail } from 'src/components/BaseEmail';
|
import { BaseEmail } from 'src/components/BaseEmail';
|
||||||
import { CallToAction } from 'src/components/CallToAction';
|
import { CallToAction } from 'src/components/CallToAction';
|
||||||
@@ -56,7 +56,7 @@ export const SendApprovedAccessDomainValidation = ({
|
|||||||
<Link
|
<Link
|
||||||
href={`mailto:${senderEmail}`}
|
href={`mailto:${senderEmail}`}
|
||||||
value={senderEmail}
|
value={senderEmail}
|
||||||
color={emailTheme.font.colors.blue}
|
color={canvasTheme.font.colors.blue}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
1: <b />,
|
1: <b />,
|
||||||
|
|||||||
@@ -1,23 +1,61 @@
|
|||||||
import { Body, Head, Html } from 'react-email';
|
import { type ReactNode } from 'react';
|
||||||
|
import { Body, Container, Head, Html } from 'react-email';
|
||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { type CanvasTheme, resolveCanvasTheme } from 'twenty-shared/utils';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
|
||||||
|
const BASE_STYLE_RESET = `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`;
|
||||||
|
|
||||||
|
const themedBody = (theme: CanvasTheme, children: ReactNode) => (
|
||||||
|
<Body
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.pageBackground,
|
||||||
|
color: theme.textColor,
|
||||||
|
margin: 0,
|
||||||
|
padding: theme.pagePadding,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.bodyBackground || undefined,
|
||||||
|
border:
|
||||||
|
theme.borderWidth !== '' && theme.borderWidth !== '0px'
|
||||||
|
? `${theme.borderWidth} solid ${theme.borderColor}`
|
||||||
|
: undefined,
|
||||||
|
borderRadius: theme.cornerRadius,
|
||||||
|
color: theme.textColor,
|
||||||
|
maxWidth: theme.width,
|
||||||
|
padding: theme.padding,
|
||||||
|
textAlign: theme.textAlign,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
);
|
||||||
|
|
||||||
export const reactMarkupFromJSON = (json: JSONContent | string) => {
|
export const reactMarkupFromJSON = (json: JSONContent | string) => {
|
||||||
if (typeof json === 'string') {
|
if (typeof json === 'string') {
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsxNodes = mappedNodeContent(json);
|
const jsxNodes = mappedNodeContent(json);
|
||||||
|
const canvasTheme = resolveCanvasTheme(json.attrs?.canvasTheme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head>
|
<Head>
|
||||||
<style
|
<style
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`,
|
__html: BASE_STYLE_RESET,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
<Body>{jsxNodes}</Body>
|
{canvasTheme !== null ? (
|
||||||
|
themedBody(canvasTheme, jsxNodes)
|
||||||
|
) : (
|
||||||
|
<Body>{jsxNodes}</Body>
|
||||||
|
)}
|
||||||
</Html>
|
</Html>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { type LinkMarkAttributes, type TipTapMark } from 'twenty-shared/utils';
|
import { type LinkMarkAttributes, type TipTapMark } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
|
||||||
|
|
||||||
export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
|
export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
|
||||||
const {
|
const {
|
||||||
href,
|
href,
|
||||||
@@ -10,7 +12,7 @@ export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={safeUrl(href)}
|
||||||
target={target}
|
target={target}
|
||||||
rel={rel}
|
rel={rel}
|
||||||
style={{ textDecoration: 'underline' }}
|
style={{ textDecoration: 'underline' }}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
export const bulletList = (node: JSONContent): ReactNode => {
|
export const bulletList = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
style={{
|
style={{
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
|
...inherited,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mappedNodeContent(node)}
|
{mappedNodeContent(node, inherited)}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Button, Column, Row } from 'react-email';
|
||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
|
||||||
|
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
|
||||||
|
|
||||||
|
export const button = (node: JSONContent): ReactNode => {
|
||||||
|
const label = (node.content ?? [])
|
||||||
|
.map((childNode) => childNode.text ?? '')
|
||||||
|
.join('');
|
||||||
|
const href = node.attrs?.href;
|
||||||
|
const align = node.attrs?.align ?? 'left';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row>
|
||||||
|
<Column align={align}>
|
||||||
|
<Button href={safeUrl(href)} style={blockStyle(node.attrs?.style)}>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Column, Row } from 'react-email';
|
||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { Fragment, type ReactNode } from 'react';
|
||||||
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
|
||||||
|
import {
|
||||||
|
type InheritedTypography,
|
||||||
|
mergeInheritedTypography,
|
||||||
|
} from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
|
export const columns = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
|
const columnNodes = node.content ?? [];
|
||||||
|
const columnWidth = `${100 / Math.max(columnNodes.length, 1)}%`;
|
||||||
|
const rowStyle = blockStyle(node.attrs?.style);
|
||||||
|
const rowTypography = mergeInheritedTypography(inherited, rowStyle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row style={rowStyle}>
|
||||||
|
{columnNodes.map((column, index) => {
|
||||||
|
const columnStyle = blockStyle(column.attrs?.style);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<Column
|
||||||
|
style={{
|
||||||
|
width: columnWidth,
|
||||||
|
verticalAlign: 'top',
|
||||||
|
...columnStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mappedNodeContent(
|
||||||
|
column,
|
||||||
|
mergeInheritedTypography(rowTypography, columnStyle),
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Hr } from 'react-email';
|
||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
|
||||||
|
|
||||||
|
export const divider = (node: JSONContent): ReactNode => {
|
||||||
|
return <Hr style={blockStyle(node.attrs?.style)} />;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { Heading } from 'react-email';
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
type HeadingLevel = 1 | 2 | 3;
|
type HeadingLevel = 1 | 2 | 3;
|
||||||
@@ -17,18 +18,23 @@ const HEADING_STYLES: Record<HeadingLevel, HeadingStyle> = {
|
|||||||
3: { element: 'h3', fontSize: '16px' },
|
3: { element: 'h3', fontSize: '16px' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const heading = (node: JSONContent): ReactNode => {
|
export const heading = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
const { level } = node?.attrs || {};
|
const { level } = node?.attrs || {};
|
||||||
|
|
||||||
if (!isDefined(level) || !HEADING_STYLES[level as HeadingLevel]) {
|
if (!isDefined(level) || !HEADING_STYLES[level as HeadingLevel]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = mappedNodeContent(node);
|
const content = mappedNodeContent(node, inherited);
|
||||||
const { element, fontSize } = HEADING_STYLES[level as HeadingLevel];
|
const { element, fontSize } = HEADING_STYLES[level as HeadingLevel];
|
||||||
|
|
||||||
|
const { fontSize: _inheritedFontSize, ...inheritedWithoutSize } = inherited;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Heading as={element} style={{ fontSize }}>
|
<Heading as={element} style={{ fontSize, ...inheritedWithoutSize }}>
|
||||||
{content}
|
{content}
|
||||||
</Heading>
|
</Heading>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export const html = (node: JSONContent): ReactNode => {
|
||||||
|
const html = node.attrs?.html;
|
||||||
|
|
||||||
|
if (typeof html !== 'string' || html === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
};
|
||||||
@@ -1,30 +1,43 @@
|
|||||||
import { Column, Row } from 'react-email';
|
import { Column, Link, Row } from 'react-email';
|
||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
|
import { safeUrl } from 'src/utils/email-renderer/utils/safe-url';
|
||||||
|
|
||||||
export const image = (node: JSONContent): ReactNode => {
|
export const image = (node: JSONContent): ReactNode => {
|
||||||
const { src, alt, align = 'left', width } = node?.attrs || {};
|
const { src: rawSrc, alt, align = 'left', width, href } = node?.attrs || {};
|
||||||
|
const src = safeUrl(rawSrc);
|
||||||
if (!isDefined(src)) {
|
if (!isDefined(src)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageElement = (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
style={{
|
||||||
|
width: isDefined(width) ? width : 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
maxWidth: '100%',
|
||||||
|
outline: 'none',
|
||||||
|
border: 'none',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
<Column align={align}>
|
<Column align={align}>
|
||||||
<img
|
{isDefined(safeUrl(href)) ? (
|
||||||
src={src}
|
<Link href={safeUrl(href)} target="_blank">
|
||||||
alt={alt}
|
{imageElement}
|
||||||
style={{
|
</Link>
|
||||||
width: isDefined(width) ? width : 'auto',
|
) : (
|
||||||
height: 'auto',
|
imageElement
|
||||||
maxWidth: '100%',
|
)}
|
||||||
outline: 'none',
|
|
||||||
border: 'none',
|
|
||||||
textDecoration: 'none',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
export const listItem = (node: JSONContent): ReactNode => {
|
export const listItem = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
style={{
|
style={{
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
|
...inherited,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mappedNodeContent(node)}
|
{mappedNodeContent(node, inherited)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
export const orderedList = (node: JSONContent): ReactNode => {
|
export const orderedList = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
style={{
|
style={{
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
|
...inherited,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mappedNodeContent(node)}
|
{mappedNodeContent(node, inherited)}
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { Text } from 'react-email';
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
import { type JSONContent } from '@tiptap/core';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
export const paragraph = (node: JSONContent): ReactNode => {
|
export const paragraph = (
|
||||||
const content = mappedNodeContent(node);
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
|
const content = mappedNodeContent(node, inherited);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text style={{ lineHeight: '1.5', margin: '0', padding: '0' }}>
|
<Text
|
||||||
|
style={{ lineHeight: '1.5', margin: '0', padding: '0', ...inherited }}
|
||||||
|
>
|
||||||
{content.length === 0 ? <> </> : content}
|
{content.length === 0 ? <> </> : content}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Section } from 'react-email';
|
||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||||
|
import { blockStyle } from 'src/utils/email-renderer/utils/block-style';
|
||||||
|
import {
|
||||||
|
type InheritedTypography,
|
||||||
|
mergeInheritedTypography,
|
||||||
|
} from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
|
||||||
|
export const section = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): ReactNode => {
|
||||||
|
const style = blockStyle(node.attrs?.style);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section style={style}>
|
||||||
|
{mappedNodeContent(node, mergeInheritedTypography(inherited, style))}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { type JSONContent } from '@tiptap/core';
|
|
||||||
import { type JSX, Fragment, type ReactNode } from 'react';
|
|
||||||
import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils';
|
|
||||||
import { bulletList } from '@/utils/email-renderer/nodes/bullet-list';
|
import { bulletList } from '@/utils/email-renderer/nodes/bullet-list';
|
||||||
|
import { button } from '@/utils/email-renderer/nodes/button';
|
||||||
|
import { columns } from '@/utils/email-renderer/nodes/columns';
|
||||||
|
import { divider } from '@/utils/email-renderer/nodes/divider';
|
||||||
|
import { html } from '@/utils/email-renderer/nodes/html';
|
||||||
|
import { section } from '@/utils/email-renderer/nodes/section';
|
||||||
import { hardBreak } from '@/utils/email-renderer/nodes/hard-break';
|
import { hardBreak } from '@/utils/email-renderer/nodes/hard-break';
|
||||||
import { heading } from '@/utils/email-renderer/nodes/heading';
|
import { heading } from '@/utils/email-renderer/nodes/heading';
|
||||||
import { image } from '@/utils/email-renderer/nodes/image';
|
import { image } from '@/utils/email-renderer/nodes/image';
|
||||||
@@ -10,6 +12,10 @@ import { orderedList } from '@/utils/email-renderer/nodes/ordered-list';
|
|||||||
import { paragraph } from '@/utils/email-renderer/nodes/paragraph';
|
import { paragraph } from '@/utils/email-renderer/nodes/paragraph';
|
||||||
import { text } from '@/utils/email-renderer/nodes/text';
|
import { text } from '@/utils/email-renderer/nodes/text';
|
||||||
import { variableTag } from '@/utils/email-renderer/nodes/variable-tag';
|
import { variableTag } from '@/utils/email-renderer/nodes/variable-tag';
|
||||||
|
import { type JSONContent } from '@tiptap/core';
|
||||||
|
import { Fragment, type JSX, type ReactNode } from 'react';
|
||||||
|
import { type InheritedTypography } from 'src/utils/email-renderer/utils/inherited-typography';
|
||||||
|
import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
|
||||||
|
|
||||||
const NODE_RENDERERS = {
|
const NODE_RENDERERS = {
|
||||||
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
|
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
|
||||||
@@ -21,23 +27,34 @@ const NODE_RENDERERS = {
|
|||||||
[TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList,
|
[TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList,
|
||||||
[TIPTAP_NODE_TYPES.LIST_ITEM]: listItem,
|
[TIPTAP_NODE_TYPES.LIST_ITEM]: listItem,
|
||||||
[TIPTAP_NODE_TYPES.HARD_BREAK]: hardBreak,
|
[TIPTAP_NODE_TYPES.HARD_BREAK]: hardBreak,
|
||||||
|
[TIPTAP_NODE_TYPES.SECTION]: section,
|
||||||
|
[TIPTAP_NODE_TYPES.COLUMNS]: columns,
|
||||||
|
[TIPTAP_NODE_TYPES.BUTTON]: button,
|
||||||
|
[TIPTAP_NODE_TYPES.DIVIDER]: divider,
|
||||||
|
[TIPTAP_NODE_TYPES.HTML]: html,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderNode = (node: JSONContent): ReactNode => {
|
const renderNode = (
|
||||||
const renderer = NODE_RENDERERS[node.type as TipTapNodeType];
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography,
|
||||||
|
): ReactNode => {
|
||||||
|
const renderer = NODE_RENDERERS[node.type as keyof typeof NODE_RENDERERS];
|
||||||
|
|
||||||
if (!renderer) {
|
if (!renderer) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderer(node);
|
return renderer(node, inherited);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mappedNodeContent = (node: JSONContent): JSX.Element[] => {
|
export const mappedNodeContent = (
|
||||||
|
node: JSONContent,
|
||||||
|
inherited: InheritedTypography = {},
|
||||||
|
): JSX.Element[] => {
|
||||||
const allNodes = node.content || [];
|
const allNodes = node.content || [];
|
||||||
return allNodes
|
return allNodes
|
||||||
.map((childNode, index) => {
|
.map((childNode, index) => {
|
||||||
const component = renderNode(childNode);
|
const component = renderNode(childNode, inherited);
|
||||||
if (!component) {
|
if (!component) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export const blockStyle = (value: unknown): CSSProperties => {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter(
|
||||||
|
(entry): entry is [string, string | number] =>
|
||||||
|
typeof entry[1] === 'string' || typeof entry[1] === 'number',
|
||||||
|
),
|
||||||
|
) as CSSProperties;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
const INHERITED_TYPOGRAPHY_PROPERTIES = [
|
||||||
|
'color',
|
||||||
|
'fontSize',
|
||||||
|
'lineHeight',
|
||||||
|
'letterSpacing',
|
||||||
|
'textAlign',
|
||||||
|
'fontFamily',
|
||||||
|
'fontWeight',
|
||||||
|
] as const satisfies readonly (keyof CSSProperties)[];
|
||||||
|
|
||||||
|
export type InheritedTypography = Pick<
|
||||||
|
CSSProperties,
|
||||||
|
(typeof INHERITED_TYPOGRAPHY_PROPERTIES)[number]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const pickInheritedTypography = (
|
||||||
|
style: CSSProperties,
|
||||||
|
): InheritedTypography =>
|
||||||
|
INHERITED_TYPOGRAPHY_PROPERTIES.reduce<InheritedTypography>(
|
||||||
|
(typography, property) =>
|
||||||
|
style[property] === undefined
|
||||||
|
? typography
|
||||||
|
: { ...typography, [property]: style[property] },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mergeInheritedTypography = (
|
||||||
|
inherited: InheritedTypography,
|
||||||
|
style: CSSProperties,
|
||||||
|
): InheritedTypography => ({
|
||||||
|
...inherited,
|
||||||
|
...pickInheritedTypography(style),
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const ALLOWED_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'];
|
||||||
|
|
||||||
|
export const safeUrl = (value: unknown): string | undefined => {
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.replace(/[\u0000-\u0020]/g, '');
|
||||||
|
const schemeMatch = normalized.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:)/);
|
||||||
|
|
||||||
|
if (schemeMatch === null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ALLOWED_SCHEMES.includes(schemeMatch[1].toLowerCase())
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true,
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"esModuleInterop": false,
|
"esModuleInterop": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"outDir": "../../.cache/tsc",
|
"outDir": "../../.cache/tsc",
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
// tsconfig.json sets noEmit for the typecheck target; the build's
|
||||||
|
// declaration emit must undo it.
|
||||||
|
"noEmit": false,
|
||||||
"types": ["node", "@nx/react/typings/image.d.ts", "vite/client"]
|
"types": ["node", "@nx/react/typings/image.d.ts", "vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
|||||||
@@ -1644,6 +1644,7 @@ export enum EngineComponentKey {
|
|||||||
DUPLICATE_WORKFLOW = 'DUPLICATE_WORKFLOW',
|
DUPLICATE_WORKFLOW = 'DUPLICATE_WORKFLOW',
|
||||||
EDIT_DASHBOARD_LAYOUT = 'EDIT_DASHBOARD_LAYOUT',
|
EDIT_DASHBOARD_LAYOUT = 'EDIT_DASHBOARD_LAYOUT',
|
||||||
EDIT_RECORD_PAGE_LAYOUT = 'EDIT_RECORD_PAGE_LAYOUT',
|
EDIT_RECORD_PAGE_LAYOUT = 'EDIT_RECORD_PAGE_LAYOUT',
|
||||||
|
EMAIL_BLOCK_SETTINGS = 'EMAIL_BLOCK_SETTINGS',
|
||||||
EXPORT_FROM_RECORD_INDEX = 'EXPORT_FROM_RECORD_INDEX',
|
EXPORT_FROM_RECORD_INDEX = 'EXPORT_FROM_RECORD_INDEX',
|
||||||
EXPORT_FROM_RECORD_SHOW = 'EXPORT_FROM_RECORD_SHOW',
|
EXPORT_FROM_RECORD_SHOW = 'EXPORT_FROM_RECORD_SHOW',
|
||||||
EXPORT_MULTIPLE_RECORDS = 'EXPORT_MULTIPLE_RECORDS',
|
EXPORT_MULTIPLE_RECORDS = 'EXPORT_MULTIPLE_RECORDS',
|
||||||
@@ -1967,6 +1968,7 @@ export enum FileFolder {
|
|||||||
Dependencies = 'Dependencies',
|
Dependencies = 'Dependencies',
|
||||||
Dpa = 'Dpa',
|
Dpa = 'Dpa',
|
||||||
EmailAttachment = 'EmailAttachment',
|
EmailAttachment = 'EmailAttachment',
|
||||||
|
EmailImage = 'EmailImage',
|
||||||
FilesField = 'FilesField',
|
FilesField = 'FilesField',
|
||||||
GeneratedSdkClient = 'GeneratedSdkClient',
|
GeneratedSdkClient = 'GeneratedSdkClient',
|
||||||
PublicAsset = 'PublicAsset',
|
PublicAsset = 'PublicAsset',
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { t } from '@lingui/core/macro';
|
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 { 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 { type MessageCampaign } from '@/activities/emails/types/MessageCampaign';
|
||||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
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`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type CampaignBodyFieldProps = {
|
type CampaignBodyFieldProps = {
|
||||||
@@ -18,6 +27,19 @@ type CampaignBodyFieldProps = {
|
|||||||
|
|
||||||
export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
|
export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
|
||||||
const { body, setBody, flush } = useCampaignBodyState({ campaign });
|
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 (
|
return (
|
||||||
<StyledContainer onBlur={() => flush()}>
|
<StyledContainer onBlur={() => flush()}>
|
||||||
@@ -26,7 +48,16 @@ export const CampaignBodyField = ({ campaign }: CampaignBodyFieldProps) => {
|
|||||||
onChange={setBody}
|
onChange={setBody}
|
||||||
placeholder={t`Type something or press "/" to see commands`}
|
placeholder={t`Type something or press "/" to see commands`}
|
||||||
preset="campaignBody"
|
preset="campaignBody"
|
||||||
|
onEditorReady={handleEditorReady}
|
||||||
|
onImageUpload={uploadEmailImage}
|
||||||
/>
|
/>
|
||||||
|
{isDefined(bodyEditor) && (
|
||||||
|
<InsertRail
|
||||||
|
editor={bodyEditor}
|
||||||
|
onImageUpload={uploadEmailImage}
|
||||||
|
variables={variables}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-6
@@ -108,6 +108,12 @@ export const CampaignDetailsFields = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledFieldsContainer onBlur={() => detailsState.flush()}>
|
<StyledFieldsContainer onBlur={() => detailsState.flush()}>
|
||||||
|
<FormTextFieldInput
|
||||||
|
label={t`Subject`}
|
||||||
|
defaultValue={detailsState.subject}
|
||||||
|
onChange={detailsState.setSubject}
|
||||||
|
placeholder={t`Subject`}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
dropdownId="campaign-composer-from-account"
|
dropdownId="campaign-composer-from-account"
|
||||||
label={t`From`}
|
label={t`From`}
|
||||||
@@ -145,12 +151,6 @@ export const CampaignDetailsFields = ({
|
|||||||
</StyledHint>
|
</StyledHint>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FormTextFieldInput
|
|
||||||
label={t`Subject`}
|
|
||||||
defaultValue={detailsState.subject}
|
|
||||||
onChange={detailsState.setSubject}
|
|
||||||
placeholder={t`Subject`}
|
|
||||||
/>
|
|
||||||
</StyledFieldsContainer>
|
</StyledFieldsContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+22
@@ -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 };
|
||||||
|
};
|
||||||
+136
-5
@@ -1,9 +1,12 @@
|
|||||||
import { ImageBubbleMenu } from '@/advanced-text-editor/components/ImageBubbleMenu';
|
import { ImageBubbleMenu } from '@/advanced-text-editor/components/ImageBubbleMenu';
|
||||||
import { LinkBubbleMenu } from '@/advanced-text-editor/components/LinkBubbleMenu';
|
import { LinkBubbleMenu } from '@/advanced-text-editor/components/LinkBubbleMenu';
|
||||||
import { TextBubbleMenu } from '@/advanced-text-editor/components/TextBubbleMenu';
|
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 { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
|
||||||
import { styled } from '@linaria/react';
|
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';
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
|
||||||
const StyledEditorContainer = styled.div<{
|
const StyledEditorContainer = styled.div<{
|
||||||
@@ -72,6 +75,54 @@ const StyledEditorContainer = styled.div<{
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: ${themeCssVariables.spacing[2]};
|
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 {
|
.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 = {
|
type AdvancedTextEditorProps = {
|
||||||
readonly: boolean | undefined;
|
readonly: boolean | undefined;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
minHeight: number;
|
minHeight: number;
|
||||||
|
chrome?: AdvancedTextEditorChrome;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TEXT_BUBBLE_MENU_EXTENSION_NAMES = [
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'underline',
|
||||||
|
'strike',
|
||||||
|
'bulletList',
|
||||||
|
'orderedList',
|
||||||
|
'heading',
|
||||||
|
'link',
|
||||||
|
];
|
||||||
|
|
||||||
export const AdvancedTextEditor = ({
|
export const AdvancedTextEditor = ({
|
||||||
readonly,
|
readonly,
|
||||||
editor,
|
editor,
|
||||||
minHeight,
|
minHeight,
|
||||||
|
chrome,
|
||||||
}: AdvancedTextEditorProps) => {
|
}: 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 (
|
return (
|
||||||
<StyledEditorContainer readonly={readonly} minHeight={minHeight}>
|
<StyledEditorContainer readonly={readonly} minHeight={minHeight}>
|
||||||
<EditorContent className="editor-content" editor={editor} />
|
{hasCanvasChrome ? (
|
||||||
<ImageBubbleMenu editor={editor} />
|
<StyledCanvasBackdrop
|
||||||
<TextBubbleMenu editor={editor} />
|
style={{
|
||||||
<LinkBubbleMenu editor={editor} />
|
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>
|
</StyledEditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-25
@@ -1,46 +1,30 @@
|
|||||||
import { styled } from '@linaria/react';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { IconComponent } from 'twenty-ui/icon';
|
import type { IconComponent } from 'twenty-ui/icon';
|
||||||
import { FloatingIconButton } from 'twenty-ui/input';
|
import { LightIconButton } from 'twenty-ui/input';
|
||||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
|
||||||
|
|
||||||
type BubbleMenuIconButtonProps = {
|
type BubbleMenuIconButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
Icon?: IconComponent;
|
Icon?: IconComponent;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
focus?: boolean;
|
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
isActive?: boolean;
|
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 = ({
|
export const BubbleMenuIconButton = ({
|
||||||
className,
|
className,
|
||||||
Icon,
|
Icon,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
focus = false,
|
|
||||||
onClick,
|
onClick,
|
||||||
isActive,
|
isActive,
|
||||||
}: BubbleMenuIconButtonProps) => {
|
}: BubbleMenuIconButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledBubbleMenuIconButtonContainer className={className}>
|
<LightIconButton
|
||||||
<FloatingIconButton
|
className={className}
|
||||||
Icon={Icon}
|
Icon={Icon}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
focus={focus}
|
onClick={onClick}
|
||||||
onClick={onClick}
|
accent={isActive === true ? 'secondary' : 'tertiary'}
|
||||||
isActive={isActive}
|
size="small"
|
||||||
applyShadow={false}
|
/>
|
||||||
applyBlur={false}
|
|
||||||
size="medium"
|
|
||||||
position="standalone"
|
|
||||||
/>
|
|
||||||
</StyledBubbleMenuIconButtonContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
|
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 { type Editor } from '@tiptap/core';
|
||||||
import { useEditorState } from '@tiptap/react';
|
import { useEditorState } from '@tiptap/react';
|
||||||
import { BubbleMenu } from '@tiptap/react/menus';
|
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
-1
@@ -1,6 +1,6 @@
|
|||||||
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
|
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
|
||||||
import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover';
|
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 { type Editor } from '@tiptap/core';
|
||||||
import { useEditorState } from '@tiptap/react';
|
import { useEditorState } from '@tiptap/react';
|
||||||
import { BubbleMenu } from '@tiptap/react/menus';
|
import { BubbleMenu } from '@tiptap/react/menus';
|
||||||
|
|||||||
+14
@@ -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;
|
||||||
|
`;
|
||||||
+21
-17
@@ -1,10 +1,12 @@
|
|||||||
|
import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/StyledBubbleMenuContainer';
|
||||||
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
|
import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton';
|
||||||
import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover';
|
import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover';
|
||||||
import { TurnIntoBlockDropdown } from '@/advanced-text-editor/components/TurnIntoBlockDropdown';
|
import { TurnIntoBlockDropdown } from '@/advanced-text-editor/components/TurnIntoBlockDropdown';
|
||||||
import { useTextBubbleState } from '@/advanced-text-editor/hooks/useTextBubbleState';
|
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 { isTextSelected } from '@/advanced-text-editor/utils/isTextSelected';
|
||||||
import { styled } from '@linaria/react';
|
|
||||||
import { type Editor } from '@tiptap/core';
|
import { type Editor } from '@tiptap/core';
|
||||||
|
import { NodeSelection } from '@tiptap/pm/state';
|
||||||
import { BubbleMenu } from '@tiptap/react/menus';
|
import { BubbleMenu } from '@tiptap/react/menus';
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
@@ -14,19 +16,6 @@ import {
|
|||||||
IconStrikethrough,
|
IconStrikethrough,
|
||||||
IconUnderline,
|
IconUnderline,
|
||||||
} from 'twenty-ui/icon';
|
} 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 = {
|
type TextBubbleMenuProps = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -37,41 +26,52 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
|
|||||||
const menuActions = [
|
const menuActions = [
|
||||||
{
|
{
|
||||||
Icon: IconBold,
|
Icon: IconBold,
|
||||||
|
extensionName: 'bold',
|
||||||
onClick: () => editor.chain().focus().toggleBold().run(),
|
onClick: () => editor.chain().focus().toggleBold().run(),
|
||||||
isActive: state.isBold,
|
isActive: state.isBold,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: IconItalic,
|
Icon: IconItalic,
|
||||||
|
extensionName: 'italic',
|
||||||
onClick: () => editor.chain().focus().toggleItalic().run(),
|
onClick: () => editor.chain().focus().toggleItalic().run(),
|
||||||
isActive: state.isItalic,
|
isActive: state.isItalic,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: IconUnderline,
|
Icon: IconUnderline,
|
||||||
|
extensionName: 'underline',
|
||||||
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
||||||
isActive: state.isUnderline,
|
isActive: state.isUnderline,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: IconStrikethrough,
|
Icon: IconStrikethrough,
|
||||||
|
extensionName: 'strike',
|
||||||
onClick: () => editor.chain().focus().toggleStrike().run(),
|
onClick: () => editor.chain().focus().toggleStrike().run(),
|
||||||
isActive: state.isStrike,
|
isActive: state.isStrike,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: IconList,
|
Icon: IconList,
|
||||||
|
extensionName: 'bulletList',
|
||||||
onClick: () => editor.chain().focus().wrapInList('bulletList').run(),
|
onClick: () => editor.chain().focus().wrapInList('bulletList').run(),
|
||||||
isActive: state.isBulletList,
|
isActive: state.isBulletList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: IconListNumbers,
|
Icon: IconListNumbers,
|
||||||
|
extensionName: 'orderedList',
|
||||||
onClick: () => editor.chain().focus().wrapInList('orderedList').run(),
|
onClick: () => editor.chain().focus().wrapInList('orderedList').run(),
|
||||||
isActive: state.isOrderedList,
|
isActive: state.isOrderedList,
|
||||||
},
|
},
|
||||||
];
|
].filter(({ extensionName }) => hasEditorExtension(editor, extensionName));
|
||||||
|
|
||||||
const handleShouldShow = () => {
|
const handleShouldShow = () => {
|
||||||
if (editor.isActive('image')) {
|
if (editor.isActive('image')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { selection } = editor.state;
|
||||||
|
if (selection instanceof NodeSelection && selection.node.isAtom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return isTextSelected({ editor });
|
return isTextSelected({ editor });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,7 +83,9 @@ export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
|
|||||||
updateDelay={0}
|
updateDelay={0}
|
||||||
>
|
>
|
||||||
<StyledBubbleMenuContainer>
|
<StyledBubbleMenuContainer>
|
||||||
<TurnIntoBlockDropdown editor={editor} />
|
{hasEditorExtension(editor, 'heading') && (
|
||||||
|
<TurnIntoBlockDropdown editor={editor} />
|
||||||
|
)}
|
||||||
{menuActions.map(({ Icon, onClick, isActive }) => {
|
{menuActions.map(({ Icon, onClick, isActive }) => {
|
||||||
return (
|
return (
|
||||||
<BubbleMenuIconButton
|
<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>
|
</StyledBubbleMenuContainer>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-4
@@ -1,4 +1,5 @@
|
|||||||
import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor';
|
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 { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
|
||||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, fn, userEvent, waitFor } from 'storybook/test';
|
import { expect, fn, userEvent, waitFor } from 'storybook/test';
|
||||||
@@ -17,16 +18,17 @@ const EditorWrapper = ({
|
|||||||
defaultValue = null,
|
defaultValue = null,
|
||||||
onUpdate = fn(),
|
onUpdate = fn(),
|
||||||
minHeight = 200,
|
minHeight = 200,
|
||||||
enableSlashCommand = true,
|
preset = 'recordRichTextField',
|
||||||
}: {
|
}: {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: string | null;
|
defaultValue?: string | null;
|
||||||
onUpdate?: (content: string) => void;
|
onUpdate?: (content: string) => void;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
enableSlashCommand?: boolean;
|
preset?: AdvancedTextEditorPresetName;
|
||||||
}) => {
|
}) => {
|
||||||
const editor = useAdvancedTextEditor({
|
const editor = useAdvancedTextEditor({
|
||||||
|
preset,
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -36,12 +38,13 @@ const EditorWrapper = ({
|
|||||||
},
|
},
|
||||||
onImageUpload: async (file: File) => {
|
onImageUpload: async (file: File) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
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) => {
|
onImageUploadError: (_error: Error, _file: File) => {
|
||||||
// Handle image upload error
|
// Handle image upload error
|
||||||
},
|
},
|
||||||
enableSlashCommand,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) {
|
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 = {
|
export const Interactive: Story = {
|
||||||
args: {
|
args: {
|
||||||
onUpdate: fn(),
|
onUpdate: fn(),
|
||||||
|
|||||||
+69
@@ -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,
|
||||||
|
],
|
||||||
|
};
|
||||||
+27
@@ -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,
|
||||||
|
];
|
||||||
+15
-1
@@ -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';
|
import { type AdvancedTextEditorPreset } from '@/advanced-text-editor/types/AdvancedTextEditorPreset';
|
||||||
|
|
||||||
// One entry per surface that renders the TipTap editor today. Add a surface
|
// 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.
|
// it to email-safe HTML at send time, like the workflow email node.
|
||||||
campaignBody: {
|
campaignBody: {
|
||||||
contentType: 'json',
|
contentType: 'json',
|
||||||
chrome: 'document',
|
chrome: 'canvas',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
enableFullScreen: false,
|
enableFullScreen: false,
|
||||||
|
capabilities: [...EMAIL_DOCUMENT_CAPABILITIES, 'campaignVariables'],
|
||||||
},
|
},
|
||||||
inlineEmailBody: {
|
inlineEmailBody: {
|
||||||
contentType: 'html',
|
contentType: 'html',
|
||||||
chrome: 'field',
|
chrome: 'field',
|
||||||
minHeight: 120,
|
minHeight: 120,
|
||||||
enableFullScreen: true,
|
enableFullScreen: true,
|
||||||
|
capabilities: FULL_RICH_TEXT_CAPABILITIES,
|
||||||
},
|
},
|
||||||
workflowEmailBody: {
|
workflowEmailBody: {
|
||||||
contentType: 'json',
|
contentType: 'json',
|
||||||
chrome: 'field',
|
chrome: 'field',
|
||||||
minHeight: 340,
|
minHeight: 340,
|
||||||
enableFullScreen: true,
|
enableFullScreen: true,
|
||||||
|
capabilities: FULL_RICH_TEXT_CAPABILITIES,
|
||||||
},
|
},
|
||||||
// The RICH_TEXT form input. Its stored value is still BlockNote JSON; only
|
// The RICH_TEXT form input. Its stored value is still BlockNote JSON; only
|
||||||
// the editing surface is TipTap today.
|
// the editing surface is TipTap today.
|
||||||
@@ -32,12 +37,21 @@ export const ADVANCED_TEXT_EDITOR_PRESETS = {
|
|||||||
chrome: 'field',
|
chrome: 'field',
|
||||||
minHeight: 340,
|
minHeight: 340,
|
||||||
enableFullScreen: true,
|
enableFullScreen: true,
|
||||||
|
capabilities: FULL_RICH_TEXT_CAPABILITIES,
|
||||||
},
|
},
|
||||||
aiInstructions: {
|
aiInstructions: {
|
||||||
contentType: 'markdown',
|
contentType: 'markdown',
|
||||||
chrome: 'field',
|
chrome: 'field',
|
||||||
minHeight: 120,
|
minHeight: 120,
|
||||||
enableFullScreen: true,
|
enableFullScreen: true,
|
||||||
|
capabilities: FULL_RICH_TEXT_CAPABILITIES,
|
||||||
|
},
|
||||||
|
aiChat: {
|
||||||
|
contentType: 'json',
|
||||||
|
chrome: 'document',
|
||||||
|
minHeight: 0,
|
||||||
|
enableFullScreen: false,
|
||||||
|
capabilities: ['mentions'],
|
||||||
},
|
},
|
||||||
} as const satisfies Record<string, AdvancedTextEditorPreset>;
|
} as const satisfies Record<string, AdvancedTextEditorPreset>;
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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[];
|
||||||
+12
@@ -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',
|
||||||
|
];
|
||||||
+77
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+39
@@ -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,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+40
@@ -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,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+48
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
+30
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+45
@@ -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,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
+18
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+11
@@ -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 {};
|
||||||
|
}
|
||||||
|
};
|
||||||
+13
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+10
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
+15
@@ -12,6 +12,21 @@ export const ResizableImage = TiptapImage.extend<ImageOptions>({
|
|||||||
align: {
|
align: {
|
||||||
default: 'left',
|
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 } : {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -10,11 +10,14 @@ const IMAGE_MAX_WIDTH = 600;
|
|||||||
const StyledNodeViewWrapperContainer = styled.div<{
|
const StyledNodeViewWrapperContainer = styled.div<{
|
||||||
align?: string;
|
align?: string;
|
||||||
}>`
|
}>`
|
||||||
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: ${({ align }) =>
|
justify-content: ${({ align }) =>
|
||||||
align === 'left' ? '0' : align === 'center' ? 'auto' : 'unset'};
|
align === 'center'
|
||||||
margin-right: ${({ align }) =>
|
? 'center'
|
||||||
align === 'right' ? '0' : align === 'center' ? 'auto' : 'unset'};
|
: align === 'right'
|
||||||
|
? 'flex-end'
|
||||||
|
: 'flex-start'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledImageWrapper = styled.div<{ width?: number }>`
|
const StyledImageWrapper = styled.div<{ width?: number }>`
|
||||||
|
|||||||
+3
-8
@@ -3,8 +3,9 @@ import {
|
|||||||
type UploadImagePluginProps,
|
type UploadImagePluginProps,
|
||||||
} from '@/advanced-text-editor/extensions/resizable-image/UploadImagePlugin';
|
} from '@/advanced-text-editor/extensions/resizable-image/UploadImagePlugin';
|
||||||
import { Extension } from '@tiptap/core';
|
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 = {
|
type UploadImageStorage = {
|
||||||
placeholderImages: Set<string>;
|
placeholderImages: Set<string>;
|
||||||
@@ -24,13 +25,7 @@ export const UploadImageExtension = Extension.create<
|
|||||||
|
|
||||||
addOptions: () => {
|
addOptions: () => {
|
||||||
return {
|
return {
|
||||||
allowedMimeTypes: [
|
allowedMimeTypes: EMAIL_IMAGE_MIME_TYPES,
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/gif',
|
|
||||||
'image/webp',
|
|
||||||
'image/svg+xml',
|
|
||||||
],
|
|
||||||
onImageUpload: undefined,
|
onImageUpload: undefined,
|
||||||
onImageUploadError: undefined,
|
onImageUploadError: undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-4
@@ -3,10 +3,12 @@ import { type Node } from '@tiptap/pm/model';
|
|||||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
import { type EditorView } from '@tiptap/pm/view';
|
import { type EditorView } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
import { type UploadedImage } from '@/advanced-text-editor/types/UploadedImage';
|
||||||
|
|
||||||
export type UploadImagePluginProps = {
|
export type UploadImagePluginProps = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
allowedMimeTypes?: string[];
|
allowedMimeTypes?: readonly string[];
|
||||||
onImageUpload?: (file: File) => Promise<string>;
|
onImageUpload?: (file: File) => Promise<UploadedImage>;
|
||||||
onImageUploadError?: (error: Error, file: File) => void;
|
onImageUploadError?: (error: Error, file: File) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export const UploadImagePlugin = (options: UploadImagePluginProps) => {
|
|||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
|
|
||||||
onImageUpload?.(file)
|
onImageUpload?.(file)
|
||||||
.then((uploadedSrc) => {
|
.then((uploadedImage) => {
|
||||||
const updateTr = view.state.tr;
|
const updateTr = view.state.tr;
|
||||||
|
|
||||||
const predicate = (node: Node) =>
|
const predicate = (node: Node) =>
|
||||||
@@ -44,7 +46,8 @@ export const UploadImagePlugin = (options: UploadImagePluginProps) => {
|
|||||||
if (predicate(node)) {
|
if (predicate(node)) {
|
||||||
updateTr.setNodeMarkup(pos, undefined, {
|
updateTr.setNodeMarkup(pos, undefined, {
|
||||||
...node.attrs,
|
...node.attrs,
|
||||||
src: uploadedSrc,
|
fileId: uploadedImage.fileId ?? null,
|
||||||
|
src: uploadedImage.url,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+122
@@ -1,16 +1,30 @@
|
|||||||
import { type MessageDescriptor } from '@lingui/core';
|
import { type MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { type Editor, type Range } from '@tiptap/core';
|
import { type Editor, type Range } from '@tiptap/core';
|
||||||
|
import { isDefined, TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
|
||||||
import {
|
import {
|
||||||
type IconComponent,
|
type IconComponent,
|
||||||
|
IconBox,
|
||||||
|
IconClick,
|
||||||
|
IconCode,
|
||||||
|
IconColumns,
|
||||||
IconH1,
|
IconH1,
|
||||||
IconH2,
|
IconH2,
|
||||||
IconH3,
|
IconH3,
|
||||||
IconList,
|
IconList,
|
||||||
IconListNumbers,
|
IconListNumbers,
|
||||||
|
IconMinus,
|
||||||
IconPilcrow,
|
IconPilcrow,
|
||||||
} from 'twenty-ui/icon';
|
} 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 = {
|
export type SlashCommandConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
title: MessageDescriptor;
|
title: MessageDescriptor;
|
||||||
@@ -68,6 +82,114 @@ export const DEFAULT_SLASH_COMMANDS: SlashCommandConfig[] = [
|
|||||||
getOnSelect: (editor, range) => () =>
|
getOnSelect: (editor, range) => () =>
|
||||||
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(),
|
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',
|
id: 'bulletList',
|
||||||
title: msg`Bullet List`,
|
title: msg`Bullet List`,
|
||||||
|
|||||||
+33
-67
@@ -1,45 +1,33 @@
|
|||||||
import { ResizableImage } from '@/advanced-text-editor/extensions/resizable-image/ResizableImage';
|
import {
|
||||||
import { UploadImageExtension } from '@/advanced-text-editor/extensions/resizable-image/UploadImageExtension';
|
ADVANCED_TEXT_EDITOR_PRESETS,
|
||||||
import { SlashCommand } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
|
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 { getInitialAdvancedTextEditorContent } from '@/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent';
|
||||||
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
|
import { type Content } from '@tiptap/core';
|
||||||
import { t } from '@lingui/core/macro';
|
import { type Editor, type EditorOptions, useEditor } from '@tiptap/react';
|
||||||
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 { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { type DependencyList, useMemo } from 'react';
|
import { type DependencyList, useMemo } from 'react';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export type AdvancedTextEditorContentType = 'json' | 'html' | 'markdown';
|
|
||||||
|
|
||||||
type UseAdvancedTextEditorProps = {
|
type UseAdvancedTextEditorProps = {
|
||||||
|
preset: AdvancedTextEditorPresetName;
|
||||||
placeholder: string | undefined;
|
placeholder: string | undefined;
|
||||||
readonly: boolean | undefined;
|
readonly: boolean | undefined;
|
||||||
defaultValue: string | undefined | null;
|
defaultValue: string | undefined | null;
|
||||||
onUpdate: (editor: Editor) => void;
|
onUpdate: (editor: Editor) => void;
|
||||||
onFocus?: (editor: Editor) => void;
|
onFocus?: (editor: Editor) => void;
|
||||||
onBlur?: (editor: Editor) => void;
|
onBlur?: (editor: Editor) => void;
|
||||||
onImageUpload?: (file: File) => Promise<string>;
|
onImageUpload?: (file: File) => Promise<UploadedImage>;
|
||||||
onImageUploadError?: (error: Error, file: File) => void;
|
onImageUploadError?: (error: Error, file: File) => void;
|
||||||
enableSlashCommand?: boolean;
|
content?: Content;
|
||||||
contentType?: AdvancedTextEditorContentType;
|
editorProps?: EditorOptions['editorProps'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAdvancedTextEditor = (
|
export const useAdvancedTextEditor = (
|
||||||
{
|
{
|
||||||
|
preset,
|
||||||
placeholder,
|
placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -48,60 +36,37 @@ export const useAdvancedTextEditor = (
|
|||||||
onBlur,
|
onBlur,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onImageUploadError,
|
onImageUploadError,
|
||||||
enableSlashCommand,
|
content,
|
||||||
contentType = 'json',
|
editorProps,
|
||||||
}: UseAdvancedTextEditorProps,
|
}: UseAdvancedTextEditorProps,
|
||||||
dependencies?: DependencyList,
|
dependencies?: DependencyList,
|
||||||
) => {
|
) => {
|
||||||
const isMarkdownMode = contentType === 'markdown';
|
const { contentType, capabilities } = ADVANCED_TEXT_EDITOR_PRESETS[preset];
|
||||||
|
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() =>
|
||||||
Document,
|
buildAdvancedTextEditorExtensions({
|
||||||
Paragraph,
|
capabilities,
|
||||||
Text,
|
context: {
|
||||||
Placeholder.configure({
|
onImageUpload,
|
||||||
placeholder: placeholder ?? t`Enter text or Type '/' for commands`,
|
onImageUploadError,
|
||||||
|
},
|
||||||
|
placeholder,
|
||||||
|
readonly,
|
||||||
}),
|
}),
|
||||||
VariableTag,
|
[capabilities, placeholder, onImageUpload, onImageUploadError, readonly],
|
||||||
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,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getEditorContent = () => {
|
const getEditorContent = (): Content | undefined => {
|
||||||
|
if (isDefined(content)) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isDefined(defaultValue)) {
|
if (!isDefined(defaultValue)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMarkdownMode) {
|
if (contentType === 'markdown') {
|
||||||
// Convert markdown to HTML, then TipTap will parse the HTML
|
// Convert markdown to HTML, then TipTap will parse the HTML
|
||||||
return marked.parse(defaultValue, { async: false }) as string;
|
return marked.parse(defaultValue, { async: false }) as string;
|
||||||
}
|
}
|
||||||
@@ -130,6 +95,7 @@ export const useAdvancedTextEditor = (
|
|||||||
editorProps: {
|
editorProps: {
|
||||||
scrollThreshold: 60,
|
scrollThreshold: 60,
|
||||||
scrollMargin: 60,
|
scrollMargin: 60,
|
||||||
|
...editorProps,
|
||||||
},
|
},
|
||||||
injectCSS: false,
|
injectCSS: false,
|
||||||
},
|
},
|
||||||
|
|||||||
+29
-42
@@ -1,3 +1,4 @@
|
|||||||
|
import { hasEditorExtension } from '@/advanced-text-editor/utils/hasEditorExtension';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { type Editor, useEditorState } from '@tiptap/react';
|
import { type Editor, useEditorState } from '@tiptap/react';
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +18,21 @@ export type TurnIntoBlockOptions = {
|
|||||||
icon: IconComponent;
|
icon: IconComponent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HEADING_ICONS: Record<number, IconComponent> = {
|
||||||
|
1: IconH1,
|
||||||
|
2: IconH2,
|
||||||
|
3: IconH3,
|
||||||
|
};
|
||||||
|
|
||||||
export const useTurnIntoBlockOptions = (editor: Editor) => {
|
export const useTurnIntoBlockOptions = (editor: Editor) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const headingTitles: Record<number, string> = {
|
||||||
|
1: t`Heading 1`,
|
||||||
|
2: t`Heading 2`,
|
||||||
|
3: t`Heading 3`,
|
||||||
|
};
|
||||||
|
|
||||||
return useEditorState({
|
return useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor }): TurnIntoBlockOptions[] => [
|
selector: ({ editor }): TurnIntoBlockOptions[] => [
|
||||||
@@ -37,48 +50,22 @@ export const useTurnIntoBlockOptions = (editor: Editor) => {
|
|||||||
return editor.isActive('paragraph');
|
return editor.isActive('paragraph');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(hasEditorExtension(editor, 'heading')
|
||||||
id: 'heading1',
|
? ([1, 2, 3] as const).map((level) => ({
|
||||||
title: t`Heading 1`,
|
id: `heading${level}`,
|
||||||
icon: IconH1,
|
title: headingTitles[level],
|
||||||
onClick: () => {
|
icon: HEADING_ICONS[level],
|
||||||
return editor.chain().focus().setHeading({ level: 1 }).run();
|
onClick: () => {
|
||||||
},
|
return editor.chain().focus().setHeading({ level }).run();
|
||||||
disabled: () => {
|
},
|
||||||
return !editor.can().setHeading({ level: 1 });
|
disabled: () => {
|
||||||
},
|
return !editor.can().setHeading({ level });
|
||||||
isActive: () => {
|
},
|
||||||
return editor.isActive('heading', { level: 1 });
|
isActive: () => {
|
||||||
},
|
return editor.isActive('heading', { level });
|
||||||
},
|
},
|
||||||
{
|
}))
|
||||||
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 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
+7
@@ -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,
|
||||||
|
});
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
export type AdvancedTextEditorCapability =
|
||||||
|
| 'basicMarks'
|
||||||
|
| 'headings'
|
||||||
|
| 'lists'
|
||||||
|
| 'links'
|
||||||
|
| 'images'
|
||||||
|
| 'variables'
|
||||||
|
| 'campaignVariables'
|
||||||
|
| 'mentions'
|
||||||
|
| 'slashCommand'
|
||||||
|
| 'blocks';
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export type AdvancedTextEditorContentType = 'json' | 'html' | 'markdown';
|
||||||
+4
-3
@@ -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
|
// '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
|
// the editor fills whatever container it is given and reads as page content
|
||||||
// rather than as an input.
|
export type AdvancedTextEditorChrome = 'field' | 'document' | 'canvas';
|
||||||
export type AdvancedTextEditorChrome = 'field' | 'document';
|
|
||||||
|
|
||||||
export type AdvancedTextEditorPreset = {
|
export type AdvancedTextEditorPreset = {
|
||||||
contentType: AdvancedTextEditorContentType;
|
contentType: AdvancedTextEditorContentType;
|
||||||
chrome: AdvancedTextEditorChrome;
|
chrome: AdvancedTextEditorChrome;
|
||||||
minHeight: number;
|
minHeight: number;
|
||||||
enableFullScreen: boolean;
|
enableFullScreen: boolean;
|
||||||
|
capabilities: readonly AdvancedTextEditorCapability[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type UploadedImage = {
|
||||||
|
fileId?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
+110
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+66
@@ -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="javascript: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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
@@ -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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
@@ -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),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
+50
@@ -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;
|
||||||
|
};
|
||||||
+16
@@ -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 { t } from '@lingui/core/macro';
|
||||||
import { Document } from '@tiptap/extension-document';
|
import { useCallback } from 'react';
|
||||||
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 { isDefined } from 'twenty-shared/utils';
|
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 { AGENT_CHAT_RESTORE_EDITOR_CONTENT_EVENT_NAME } from '@/ai/constants/AgentChatRestoreEditorContentEventName';
|
||||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +14,6 @@ import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
|||||||
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
|
import { dispatchAgentChatEnsureThreadForDraftEvent } from '@/ai/utils/dispatchAgentChatEnsureThreadForDraftEvent';
|
||||||
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
|
import { dispatchAgentChatSendMessageEvent } from '@/ai/utils/dispatchAgentChatSendMessageEvent';
|
||||||
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
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 { useMentionSearch } from '@/mention/hooks/useMentionSearch';
|
||||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||||
@@ -55,26 +48,12 @@ export const useAiChatEditor = () => {
|
|||||||
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
|
const initialDraft = agentChatDraftsByThreadId[draftKey] ?? '';
|
||||||
const initialContent = textToTiptapContent(initialDraft);
|
const initialContent = textToTiptapContent(initialDraft);
|
||||||
|
|
||||||
const extensions = useMemo(
|
const editor = useAdvancedTextEditor({
|
||||||
() => [
|
preset: 'aiChat',
|
||||||
Document,
|
placeholder: t`Ask, search or make anything...`,
|
||||||
Paragraph,
|
readonly: false,
|
||||||
Text,
|
defaultValue: undefined,
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: t`Ask, search or make anything...`,
|
|
||||||
}),
|
|
||||||
HardBreak.configure({
|
|
||||||
keepMarks: false,
|
|
||||||
}),
|
|
||||||
MentionTag,
|
|
||||||
MentionSuggestion,
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
content: initialContent,
|
content: initialContent,
|
||||||
extensions,
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
@@ -95,7 +74,7 @@ export const useAiChatEditor = () => {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: currentEditor }) => {
|
onUpdate: (currentEditor) => {
|
||||||
const text = turnIntoEmptyStringIfWhitespacesOnly(
|
const text = turnIntoEmptyStringIfWhitespacesOnly(
|
||||||
currentEditor.getText({ blockSeparator: '\n' }),
|
currentEditor.getText({ blockSeparator: '\n' }),
|
||||||
);
|
);
|
||||||
@@ -120,7 +99,6 @@ export const useAiChatEditor = () => {
|
|||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
removeFocusItemFromFocusStackById({ focusId: AI_CHAT_INPUT_ID });
|
removeFocusItemFromFocusStackById({ focusId: AI_CHAT_INPUT_ID });
|
||||||
},
|
},
|
||||||
injectCSS: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep search function in sync via Tiptap extension storage,
|
// Keep search function in sync via Tiptap extension storage,
|
||||||
|
|||||||
+4
@@ -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 { 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 { 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 { 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 { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
|
||||||
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
|
import { DeleteRecordsCommand } from '@/command-menu-item/engine-command/record/components/DeleteRecordsCommand';
|
||||||
import { DestroyRecordsCommand } from '@/command-menu-item/engine-command/record/components/DestroyRecordsCommand';
|
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]: (
|
[EngineComponentKey.SEND_MESSAGE_CAMPAIGN_TEST]: (
|
||||||
<SendMessageCampaignTestSingleRecordCommand />
|
<SendMessageCampaignTestSingleRecordCommand />
|
||||||
),
|
),
|
||||||
|
[EngineComponentKey.EMAIL_BLOCK_SETTINGS]: (
|
||||||
|
<OpenEmailBlockSettingsSingleRecordCommand />
|
||||||
|
),
|
||||||
|
|
||||||
// Deprecated keys kept for backward compatibility until migration runs
|
// Deprecated keys kept for backward compatibility until migration runs
|
||||||
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
|
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
|
||||||
|
|||||||
+14
@@ -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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
-1
@@ -329,7 +329,6 @@ export const RecordDetailRelationSectionDropdownToMany = ({
|
|||||||
createNewRecordAndOpenSidePanel,
|
createNewRecordAndOpenSidePanel,
|
||||||
createTargetRecord,
|
createTargetRecord,
|
||||||
dropdownId,
|
dropdownId,
|
||||||
fieldName,
|
|
||||||
isMorphJunction,
|
isMorphJunction,
|
||||||
isJunctionRelation,
|
isJunctionRelation,
|
||||||
junctionConfig,
|
junctionConfig,
|
||||||
|
|||||||
+23
-14
@@ -4,6 +4,8 @@ import {
|
|||||||
type AdvancedTextEditorPresetName,
|
type AdvancedTextEditorPresetName,
|
||||||
} from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
|
} from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
|
||||||
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
|
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 { 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 { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||||
import { InputHint } from '@/ui/input/components/InputHint';
|
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 { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
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 { isDefined } from 'twenty-shared/utils';
|
||||||
import { IconMaximize } from 'twenty-ui/icon';
|
import { IconMaximize } from 'twenty-ui/icon';
|
||||||
import { LightIconButton } from 'twenty-ui/input';
|
import { LightIconButton } from 'twenty-ui/input';
|
||||||
@@ -97,13 +100,14 @@ type FormAdvancedTextFieldInputProps = {
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
VariablePicker?: VariablePickerComponent;
|
VariablePicker?: VariablePickerComponent;
|
||||||
onImageUpload?: (file: File) => Promise<string>;
|
onImageUpload?: (file: File) => Promise<UploadedImage>;
|
||||||
onImageUploadError?: (error: Error, file: File) => void;
|
onImageUploadError?: (error: Error, file: File) => void;
|
||||||
preset: AdvancedTextEditorPresetName;
|
preset: AdvancedTextEditorPresetName;
|
||||||
// Escape hatch for surfaces that share a preset but need their own height.
|
// Escape hatch for surfaces that share a preset but need their own height.
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
enableFullScreen?: boolean;
|
enableFullScreen?: boolean;
|
||||||
fullScreenBreadcrumbs?: BreadcrumbProps['links'];
|
fullScreenBreadcrumbs?: BreadcrumbProps['links'];
|
||||||
|
onEditorReady?: (editor: Editor | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormAdvancedTextFieldInput = ({
|
export const FormAdvancedTextFieldInput = ({
|
||||||
@@ -121,6 +125,7 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
minHeight,
|
minHeight,
|
||||||
enableFullScreen,
|
enableFullScreen,
|
||||||
fullScreenBreadcrumbs,
|
fullScreenBreadcrumbs,
|
||||||
|
onEditorReady,
|
||||||
}: FormAdvancedTextFieldInputProps) => {
|
}: FormAdvancedTextFieldInputProps) => {
|
||||||
const {
|
const {
|
||||||
contentType,
|
contentType,
|
||||||
@@ -143,17 +148,12 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
|
|
||||||
const editor = useAdvancedTextEditor(
|
const editor = useAdvancedTextEditor(
|
||||||
{
|
{
|
||||||
|
preset,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
readonly,
|
readonly,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
contentType,
|
|
||||||
onUpdate: (editor) => {
|
onUpdate: (editor) => {
|
||||||
if (contentType === 'markdown' || contentType === 'html') {
|
onChange?.(serializeAdvancedTextEditorContent({ editor, contentType }));
|
||||||
onChange?.(editor.getHTML());
|
|
||||||
} else {
|
|
||||||
const jsonContent = editor.getJSON();
|
|
||||||
onChange?.(JSON.stringify(jsonContent));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
pushFocusItemToFocusStack({
|
pushFocusItemToFocusStack({
|
||||||
@@ -172,11 +172,18 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
},
|
},
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onImageUploadError,
|
onImageUploadError,
|
||||||
enableSlashCommand: true,
|
|
||||||
},
|
},
|
||||||
[isFullScreen],
|
[isFullScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEditorReady?.(editor);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onEditorReady?.(null);
|
||||||
|
};
|
||||||
|
}, [editor, onEditorReady]);
|
||||||
|
|
||||||
const handleEnterFullScreen = () => {
|
const handleEnterFullScreen = () => {
|
||||||
setIsFullScreen(true);
|
setIsFullScreen(true);
|
||||||
};
|
};
|
||||||
@@ -209,6 +216,10 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
hasClosePageButton: !isMobile,
|
hasClosePageButton: !isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isDefined(editor)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fullScreenOverlay = isFullScreenEnabled
|
const fullScreenOverlay = isFullScreenEnabled
|
||||||
? renderFullScreenModal(
|
? renderFullScreenModal(
|
||||||
<div data-globally-prevent-click-outside="true">
|
<div data-globally-prevent-click-outside="true">
|
||||||
@@ -217,6 +228,7 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
minHeight={editorMinHeight}
|
minHeight={editorMinHeight}
|
||||||
|
chrome={chrome}
|
||||||
/>
|
/>
|
||||||
</StyledFullScreenEditorContainer>
|
</StyledFullScreenEditorContainer>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -224,10 +236,6 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!isDefined(editor)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledAdvancedTextFieldContainerWrapper
|
<StyledAdvancedTextFieldContainerWrapper
|
||||||
@@ -245,6 +253,7 @@ export const FormAdvancedTextFieldInput = ({
|
|||||||
editor={editor}
|
editor={editor}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
minHeight={editorMinHeight}
|
minHeight={editorMinHeight}
|
||||||
|
chrome={chrome}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -263,7 +263,6 @@ export const RelationOneToManyFieldInput = () => {
|
|||||||
createNewRecordAndOpenSidePanel,
|
createNewRecordAndOpenSidePanel,
|
||||||
createTargetRecord,
|
createTargetRecord,
|
||||||
createJunctionRecord,
|
createJunctionRecord,
|
||||||
fieldName,
|
|
||||||
instanceId,
|
instanceId,
|
||||||
isMorphJunction,
|
isMorphJunction,
|
||||||
isJunctionRelation,
|
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 { SidePanelNavigationMenuItemEditPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNavigationMenuItemEditPage';
|
||||||
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
|
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
|
||||||
import { SidePanelAiChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAiChatThreadsPage';
|
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 { SidePanelAskAiPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAiPage';
|
||||||
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
|
import { SidePanelComposeEmailPage } from '@/side-panel/pages/compose-email/components/SidePanelComposeEmailPage';
|
||||||
import { SidePanelSendCampaignTestPage } from '@/side-panel/pages/send-campaign-test/components/SidePanelSendCampaignTestPage';
|
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.CommandMenuEdit, <SidePanelCommandMenuItemEditPage />],
|
||||||
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
|
[SidePanelPages.ComposeEmail, <SidePanelComposeEmailPage />],
|
||||||
[SidePanelPages.SendCampaignTest, <SidePanelSendCampaignTestPage />],
|
[SidePanelPages.SendCampaignTest, <SidePanelSendCampaignTestPage />],
|
||||||
|
[SidePanelPages.EmailBlockSettings, <SidePanelEmailBlockSettingsPage />],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
+22
@@ -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 };
|
||||||
|
};
|
||||||
+72
@@ -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>
|
||||||
|
);
|
||||||
+87
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
+151
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+74
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user