Slash Command Implementation in Advanced Text Editor (#15488)

Part of Fixing Issue #14976 

### Pull Request Summary: SlashCommand Integration in Advanced Text
Editor

This pull request introduces **SlashCommand functionality** within the
Advanced Text Editor, specifically used in the **Workflow node** of
**Send Email body** components. The implementation leverages the
`@tiptap/suggestion` extension from the TipTap ecosystem, enabling
dynamic styling and command execution via a custom dropdown triggered by
typing `/`.

---

### Implementation Overview

#### 1. **Custom Extension & Dropdown Rendering**
- A new extension was created to handle SlashCommand interactions.
- This extension renders a **Dropdown component** that displays
available commands with relevant styles, icons etc.

#### 2. **Command Configuration**
- Each command includes:
  - Visibility and active state logic
  - Execution behavior upon selection
- All commands are initialized and configured centrally.

#### 3. **Search & Filtering**
- As users type after the `/`, the command list is **filtered based on
the query**.
- For example, typing `/car` filters and displays matching commands in
the dropdown.

#### 4. **Dropdown Lifecycle & Positioning**
- The dropdown is rendered using React lifecycle hooks provided by
`SuggestionTip`:
- `onStart`: Initializes state, sets selected command, and captures
cursor position via `DOMRect`.
- `onUpdate`, `onKeyDown`, `onExit`: Manage dropdown updates and
interactions.
- Positioning is handled via a **`useFloating` hook**, which aligns the
dropdown relative to the cursor and editor bounds.
- The dropdown is rendered in a **react-portal**, wrapped in the current
theme for consistent styling and animation.


#### 6. **State Management**
- A dedicated `SlashCommandState.ts` file manages:
  - Callback functions
  - Current command and selected item
  - Cleanup utilities for event listeners
  - Dropdown navigation (arrow keys, enter key)

#### 7. **Integration Points**
- SlashCommand functionality is now **enabled in Send Email body of the
Workflow**.
- Relevant changes have been applied to support this components in
Storybook as well.

[slash command
test.webm](https://github.com/user-attachments/assets/5c537844-8987-4ce5-9fca-b29ea93d8063)


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Updates Portuguese (Brazil and Portugal) generated locale files,
including a new editor hint string for “Enter text or type '/' for
commands,” plus assorted translation tweaks.
> 
> - **i18n/locales**:
> - **Portuguese (Brazil) `src/locales/generated/pt-BR.ts`**: Add new
editor hint message (`"Enter text or Type '/' for commands"`) and adjust
multiple translations/wording.
> - **Portuguese (Portugal) `src/locales/generated/pt-PT.ts`**: Add the
same editor hint message and refine numerous translations/labels.
> - No functional code changes; translation files regenerated/updated.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
b3d7407525ec08da1fcfafe1309bb4fd61654dac. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Rounak Joshi
2025-11-10 01:48:56 +05:30
committed by GitHub
parent 1aa9df1d2d
commit 9f594cda1c
75 changed files with 1539 additions and 35 deletions
+1
View File
@@ -115,6 +115,7 @@
"@lingui/cli": "^5.1.2",
"@lingui/swc-plugin": "^5.6.0",
"@lingui/vite-plugin": "^5.1.2",
"@tiptap/suggestion": "^3.4.2",
"@types/apollo-upload-client": "^17.0.2",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.3",
@@ -3653,6 +3653,11 @@ msgstr "Voer 'n getal tussen 0 en 59 in"
msgid "Enter number greater than 1"
msgstr "Voer 'n getal groter as 1 in"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "أدخل رقمًا بين 0 و 59"
msgid "Enter number greater than 1"
msgstr "أدخل رقمًا أكبر من 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Introdueix un número entre 0 i 59"
msgid "Enter number greater than 1"
msgstr "Introdueix un número més gran que 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Zadejte číslo mezi 0 a 59"
msgid "Enter number greater than 1"
msgstr "Zadejte číslo větší než 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Indtast et tal mellem 0 og 59"
msgid "Enter number greater than 1"
msgstr "Indtast et tal større end 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Geben Sie eine Zahl zwischen 0 und 59 ein"
msgid "Enter number greater than 1"
msgstr "Geben Sie eine Zahl größer als 1 ein"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Εισάγετε αριθμό μεταξύ 0 και 59"
msgid "Enter number greater than 1"
msgstr "Εισάγετε αριθμό μεγαλύτερο από 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
+5
View File
@@ -3648,6 +3648,11 @@ msgstr "Enter number between 0 and 59"
msgid "Enter number greater than 1"
msgstr "Enter number greater than 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr "Enter text or Type '/' for commands"
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Introduce un número entre 0 y 59"
msgid "Enter number greater than 1"
msgstr "Introduce un número mayor que 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Syötä luku 0:n ja 59:n väliltä"
msgid "Enter number greater than 1"
msgstr "Syötä luku, joka on suurempi kuin 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Entrez un nombre entre 0 et 59"
msgid "Enter number greater than 1"
msgstr "Entrez un nombre supérieur à 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3653,6 +3653,11 @@ msgstr "הזן מספר בין 0 ל-59"
msgid "Enter number greater than 1"
msgstr "הזן מספר גדול מ-1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Adjon meg egy számot 0 és 59 között"
msgid "Enter number greater than 1"
msgstr "Adjon meg egy számot, amely nagyobb, mint 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Immettere un numero tra 0 e 59"
msgid "Enter number greater than 1"
msgstr "Immettere un numero maggiore di 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "0 から 59 の間の数字を入力してください"
msgid "Enter number greater than 1"
msgstr "1 より大きい数字を入力してください"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "0에서 59 사이의 숫자를 입력하세요"
msgid "Enter number greater than 1"
msgstr "1보다 큰 숫자를 입력하세요"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Voer een getal in tussen 0 en 59"
msgid "Enter number greater than 1"
msgstr "Voer een getal in groter dan 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Skriv inn et tall mellom 0 og 59"
msgid "Enter number greater than 1"
msgstr "Skriv inn et tall større enn 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Wprowadź liczbę między 0 a 59"
msgid "Enter number greater than 1"
msgstr "Wprowadź liczbę większą niż 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3648,6 +3648,11 @@ msgstr ""
msgid "Enter number greater than 1"
msgstr ""
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Insira um número entre 0 e 59"
msgid "Enter number greater than 1"
msgstr "Insira um número maior que 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Insira um número entre 0 e 59"
msgid "Enter number greater than 1"
msgstr "Insira um número maior que 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Introduceți un număr între 0 și 59"
msgid "Enter number greater than 1"
msgstr "Introduceți un număr mai mare de 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
Binary file not shown.
@@ -3653,6 +3653,11 @@ msgstr "Унесите број између 0 и 59"
msgid "Enter number greater than 1"
msgstr "Унесите број већи од 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Ange ett nummer mellan 0 och 59"
msgid "Enter number greater than 1"
msgstr "Ange ett nummer större än 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "0 ile 59 arasında bir sayı giriniz"
msgid "Enter number greater than 1"
msgstr "1'den büyük bir sayı giriniz"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Введіть число між 0 та 59"
msgid "Enter number greater than 1"
msgstr "Введіть число більше 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "Nhập số từ 0 đến 59"
msgid "Enter number greater than 1"
msgstr "Nhập số lớn hơn 1"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "输入一个 0 到 59 之间的数字"
msgid "Enter number greater than 1"
msgstr "输入一个大于 1 的数字"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -3653,6 +3653,11 @@ msgstr "輸入位於 0 和 59 之間的數字"
msgid "Enter number greater than 1"
msgstr "輸入大於 1 的數字"
#. js-lingui-id: yZMXC2
#: src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts
msgid "Enter text or Type '/' for commands"
msgstr ""
#. js-lingui-id: XJU8BD
#: src/modules/settings/security/components/SSO/SettingsSSOOIDCForm.tsx
msgid "Enter the credentials to set the connection"
@@ -19,6 +19,7 @@ const EditorWrapper = ({
onUpdate = fn(),
minHeight = 200,
maxWidth = 800,
enableSlashCommand = true,
}: {
readonly?: boolean;
placeholder?: string;
@@ -26,6 +27,7 @@ const EditorWrapper = ({
onUpdate?: (content: string) => void;
minHeight?: number;
maxWidth?: number;
enableSlashCommand?: boolean;
}) => {
const editor = useAdvancedTextEditor({
placeholder,
@@ -42,6 +44,7 @@ const EditorWrapper = ({
onImageUploadError: (_error: Error, _file: File) => {
// Handle image upload error
},
enableSlashCommand,
});
if (!editor) {
@@ -0,0 +1,93 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { type Editor, type Range } from '@tiptap/core';
import {
type IconComponent,
IconH1,
IconH2,
IconH3,
IconList,
IconListNumbers,
IconPilcrow,
} from 'twenty-ui/display';
export type SlashCommandConfig = {
id: string;
title: MessageDescriptor;
description: MessageDescriptor;
icon: IconComponent;
keywords: MessageDescriptor[];
getIsActive: (editor: Editor) => boolean;
getIsVisible: (editor: Editor) => boolean;
getOnSelect: (editor: Editor, range: Range) => () => void;
};
export const DEFAULT_SLASH_COMMANDS: SlashCommandConfig[] = [
{
id: 'paragraph',
title: msg`Text`,
description: msg`Plain text paragraph`,
icon: IconPilcrow,
keywords: [msg`paragraph`, msg`text`, msg`p`],
getIsActive: (editor) => editor.isActive('paragraph'),
getIsVisible: (editor) => editor.can().setParagraph?.() ?? false,
getOnSelect: (editor, range) => () => {
return editor.chain().focus().deleteRange(range).setParagraph().run();
},
},
{
id: 'h1',
title: msg`Heading 1`,
description: msg`Large section heading`,
icon: IconH1,
keywords: [msg`heading`, msg`h1`, msg`title`],
getIsActive: (editor) => editor.isActive('heading', { level: 1 }),
getIsVisible: (editor) => editor.can().setHeading?.({ level: 1 }) ?? false,
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(),
},
{
id: 'h2',
title: msg`Heading 2`,
description: msg`Medium section heading`,
icon: IconH2,
keywords: [msg`heading`, msg`h2`, msg`subtitle`],
getIsActive: (editor) => editor.isActive('heading', { level: 2 }),
getIsVisible: (editor) => editor.can().setHeading?.({ level: 2 }) ?? false,
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(),
},
{
id: 'h3',
title: msg`Heading 3`,
description: msg`Small section heading`,
icon: IconH3,
keywords: [msg`heading`, msg`h3`, msg`subheading`],
getIsActive: (editor) => editor.isActive('heading', { level: 3 }),
getIsVisible: (editor) => editor.can().setHeading?.({ level: 3 }) ?? false,
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(),
},
{
id: 'bulletList',
title: msg`Bullet List`,
description: msg`Unordered list with bullets`,
icon: IconList,
keywords: [msg`bullet`, msg`list`, msg`ul`, msg`unordered`],
getIsActive: (editor) => editor.isActive('bulletList'),
getIsVisible: (editor) => editor.can().toggleBulletList?.() ?? false,
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).toggleBulletList().run(),
},
{
id: 'orderedList',
title: msg`Ordered List`,
description: msg`Numbered list`,
icon: IconListNumbers,
keywords: [msg`ordered`, msg`list`, msg`ol`, msg`numbered`, msg`number`],
getIsActive: (editor) => editor.isActive('orderedList'),
getIsVisible: (editor) => editor.can().toggleOrderedList?.() ?? false,
getOnSelect: (editor, range) => () =>
editor.chain().focus().deleteRange(range).toggleOrderedList().run(),
},
];
@@ -0,0 +1,168 @@
import { i18n } from '@lingui/core';
import { Extension, type Editor, type Range } from '@tiptap/core';
import Suggestion, { type SuggestionOptions } from '@tiptap/suggestion';
import { type IconComponent } from 'twenty-ui/display';
import {
DEFAULT_SLASH_COMMANDS,
type SlashCommandConfig,
} from '@/advanced-text-editor/extensions/slash-command/DefaultSlashCommands';
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
export type SlashCommandItem = {
id: string;
title: string;
description?: string;
icon?: IconComponent;
keywords?: string[];
isActive?: () => boolean;
isVisible?: () => boolean;
command: (options: { editor: Editor; range: Range }) => void;
};
type SuggestionRenderProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect?: (() => DOMRect | null) | null;
range: Range;
query: string;
};
const createSlashCommandItem = (
config: SlashCommandConfig,
editor: Editor,
): SlashCommandItem => ({
id: config.id,
title: i18n._(config.title),
description: i18n._(config.description),
icon: config.icon,
keywords: config.keywords.map((keyword) => i18n._(keyword)),
isActive: () => config.getIsActive(editor),
isVisible: () => config.getIsVisible(editor),
command: ({ editor: ed, range }) => {
const onSelect = config.getOnSelect(ed, range);
onSelect();
},
});
const filterVisibleItems = (items: SlashCommandItem[]): SlashCommandItem[] => {
return items.filter((item) => item.isVisible?.() ?? true);
};
const filterByQuery = (
items: SlashCommandItem[],
query: string,
): SlashCommandItem[] => {
if (!query.trim()) {
return items;
}
const normalizedQuery = query.trim().toLowerCase();
return items.filter((item) => {
const searchableText =
`${item.title} ${(item.keywords ?? []).join(' ')}`.toLowerCase();
return searchableText.includes(normalizedQuery);
});
};
const buildItems = (editor: Editor, query: string): SlashCommandItem[] => {
const allItems = DEFAULT_SLASH_COMMANDS.map((config) =>
createSlashCommandItem(config, editor),
);
const visibleItems = filterVisibleItems(allItems);
return filterByQuery(visibleItems, query);
};
export type SlashCommandOptions = {
suggestions: Omit<SuggestionOptions, 'editor'>;
};
export const SlashCommand = Extension.create<SlashCommandOptions>({
name: 'slash-command',
addOptions: () => {
return {
suggestions: {
char: '/',
command: ({ editor, range, props }) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestions,
items: ({ query, editor: ed }) => buildItems(ed, query),
render: () => {
let component: SlashCommandRenderer | null = null;
const closeMenu = () => {
if (component !== null) {
component.destroy();
component = null;
}
};
return {
onStart: (props: SuggestionRenderProps) => {
if (!props.clientRect) {
return;
}
component = new SlashCommandRenderer({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
},
onUpdate: (props: SuggestionRenderProps) => {
if (component === null) {
return;
}
if (!props.clientRect) {
return;
}
if (props.items.length === 0) {
closeMenu();
return;
}
component.updateProps({
items: props.items,
command: (item: SlashCommandItem) => {
props.command(item);
closeMenu();
},
clientRect: props.clientRect,
editor: this.editor,
range: props.range,
query: props.query,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'Escape') {
closeMenu();
return true;
}
return component?.ref?.onKeyDown?.(props) ?? false;
},
onExit: () => {
closeMenu();
},
};
},
}),
];
},
});
@@ -0,0 +1,220 @@
import { ThemeProvider } from '@emotion/react';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { type Editor, type Range } from '@tiptap/core';
import { motion } from 'framer-motion';
import {
forwardRef,
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
export type SlashCommandMenuProps = {
items: SlashCommandItem[];
onSelect: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
export const SlashCommandMenu = forwardRef<unknown, SlashCommandMenuProps>(
(props, parentRef) => {
const { items, onSelect, editor, range, query } = props;
const colorScheme = document.documentElement.className.includes('dark')
? 'Dark'
: 'Light';
const theme = colorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
const [selectedIndex, setSelectedIndex] = useState(0);
const [prevSelectedIndex, setPrevSelectedIndex] = useState(0);
const [prevQuery, setPrevQuery] = useState('');
const activeCommandRef = useRef<HTMLDivElement>(null);
const commandListContainerRef = useRef<HTMLDivElement>(null);
const positionReference = useMemo(
() => ({
getBoundingClientRect: () => {
const start = editor.view.coordsAtPos(range.from);
return new DOMRect(
start.left,
start.top,
0,
start.bottom - start.top,
);
},
}),
[editor, range],
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(4), flip(), shift()],
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: true,
});
},
elements: {
reference: positionReference,
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (!item) {
return;
}
onSelect(item);
},
[onSelect, items],
);
useImperativeHandle(parentRef, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
const navigationKeys = [
'ArrowUp',
'ArrowDown',
'Enter',
'ArrowLeft',
'ArrowRight',
];
if (navigationKeys.includes(event.key)) {
let newCommandIndex = selectedIndex;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
editor
.chain()
.focus()
.insertContentAt(range, `/${prevQuery}`)
.run();
setTimeout(() => {
setSelectedIndex(prevSelectedIndex);
}, 0);
return true;
case 'ArrowUp':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex - 1;
if (newCommandIndex < 0) {
newCommandIndex = items.length - 1;
}
setSelectedIndex(newCommandIndex);
return true;
case 'ArrowDown':
if (!items.length) {
return false;
}
newCommandIndex = selectedIndex + 1;
if (newCommandIndex >= items.length) {
newCommandIndex = 0;
}
setSelectedIndex(newCommandIndex);
return true;
case 'Enter':
if (!items.length) {
return false;
}
selectItem(selectedIndex);
setPrevQuery(query);
setPrevSelectedIndex(selectedIndex);
return true;
default:
return false;
}
}
},
}));
useLayoutEffect(() => {
const container = commandListContainerRef?.current;
const activeCommandContainer = activeCommandRef?.current;
if (!container || !activeCommandContainer) {
return;
}
const scrollableContainer =
container.firstElementChild as HTMLElement | null;
if (!scrollableContainer) {
return;
}
const { offsetTop, offsetHeight } = activeCommandContainer;
scrollableContainer.style.transition = 'none';
scrollableContainer.scrollTop = offsetTop - offsetHeight;
}, [selectedIndex]);
return (
<ThemeProvider theme={theme}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
data-slash-command-menu
>
<OverlayContainer
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
}}
>
<DropdownContent ref={commandListContainerRef}>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<div
key={item.id}
ref={isSelected ? activeCommandRef : null}
onMouseDown={(e) => {
e.preventDefault();
}}
>
<MenuItemSuggestion
LeftIcon={item.icon}
text={item.title}
selected={isSelected}
onClick={() => {
onSelect(item);
}}
/>
</div>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>
</ThemeProvider>
);
},
);
@@ -0,0 +1,88 @@
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
import {
SlashCommandMenu,
type SlashCommandMenuProps,
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
import type { Editor, Range } from '@tiptap/core';
type SlashCommandRendererProps = {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
editor: Editor;
range: Range;
query: string;
};
export class SlashCommandRenderer {
componentRoot: Root | null = null;
containerElement: HTMLElement | null = null;
currentProps: SlashCommandRendererProps | null = null;
ref: { onKeyDown?: (props: { event: KeyboardEvent }) => boolean } | null =
null;
constructor(props: SlashCommandRendererProps) {
this.containerElement = document.createElement('div');
document.body.appendChild(this.containerElement);
this.componentRoot = createRoot(this.containerElement);
this.currentProps = props;
this.render(props);
}
render(props: SlashCommandRendererProps): void {
if (!this.componentRoot) {
return;
}
const menuProps: SlashCommandMenuProps = {
items: props.items,
onSelect: props.command,
clientRect: props.clientRect,
editor: props.editor,
range: props.range,
query: props.query,
};
this.componentRoot.render(
createElement(SlashCommandMenu, {
...menuProps,
ref: (
ref: {
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
} | null,
) => {
this.ref = ref;
},
}),
);
}
updateProps(props: Partial<SlashCommandRendererProps>): void {
if (!this.componentRoot || !this.currentProps) {
return;
}
const updatedProps = { ...this.currentProps, ...props };
this.currentProps = updatedProps;
this.render(updatedProps);
}
destroy(): void {
if (this.componentRoot !== null) {
this.componentRoot.unmount();
this.componentRoot = null;
}
if (this.containerElement !== null) {
this.containerElement.remove();
this.containerElement = null;
}
this.currentProps = null;
this.ref = null;
}
}
@@ -0,0 +1,284 @@
import { i18n } from '@lingui/core';
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Heading } from '@tiptap/extension-heading';
import { ListKit } from '@tiptap/extension-list';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { DEFAULT_SLASH_COMMANDS } from '../DefaultSlashCommands';
describe('DefaultSlashCommands', () => {
let editor: Editor;
beforeEach(() => {
// Initialize i18n for tests
i18n.load('en', {});
i18n.activate('en');
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Heading.configure({ levels: [1, 2, 3] }),
ListKit,
],
content: '<p></p>',
});
});
afterEach(() => {
editor?.destroy();
});
describe('Command structure', () => {
it('should have all required fields for each command', () => {
DEFAULT_SLASH_COMMANDS.forEach((command) => {
expect(command).toHaveProperty('id');
expect(command).toHaveProperty('title');
expect(command).toHaveProperty('description');
expect(command).toHaveProperty('icon');
expect(command).toHaveProperty('keywords');
expect(command).toHaveProperty('getIsActive');
expect(command).toHaveProperty('getIsVisible');
expect(command).toHaveProperty('getOnSelect');
// Verify functions are actually functions
expect(typeof command.getIsActive).toBe('function');
expect(typeof command.getIsVisible).toBe('function');
expect(typeof command.getOnSelect).toBe('function');
});
});
it('should have unique IDs for each command', () => {
const ids = DEFAULT_SLASH_COMMANDS.map((cmd) => cmd.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should have translatable message descriptors for titles', () => {
DEFAULT_SLASH_COMMANDS.forEach((command) => {
// MessageDescriptor should have an id property
expect(command.title).toHaveProperty('id');
expect(typeof command.title.id).toBe('string');
});
});
it('should have translatable message descriptors for descriptions', () => {
DEFAULT_SLASH_COMMANDS.forEach((command) => {
expect(command.description).toHaveProperty('id');
expect(typeof command.description.id).toBe('string');
});
});
it('should have translatable message descriptors for keywords', () => {
DEFAULT_SLASH_COMMANDS.forEach((command) => {
expect(Array.isArray(command.keywords)).toBe(true);
command.keywords.forEach((keyword) => {
expect(keyword).toHaveProperty('id');
expect(typeof keyword.id).toBe('string');
});
});
});
});
describe('Paragraph command', () => {
it('should be active when paragraph is active', () => {
const paragraphCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'paragraph',
);
editor.commands.setContent('<p>Test</p>');
expect(paragraphCommand?.getIsActive(editor)).toBe(true);
});
it('should not be active when heading is active', () => {
const paragraphCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'paragraph',
);
editor.commands.setContent('<h1>Test</h1>');
expect(paragraphCommand?.getIsActive(editor)).toBe(false);
});
it('should have visibility logic defined', () => {
const paragraphCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'paragraph',
);
// Paragraph command should have a getIsVisible function
expect(typeof paragraphCommand?.getIsVisible).toBe('function');
// Result should be a boolean
expect(typeof paragraphCommand?.getIsVisible(editor)).toBe('boolean');
});
});
describe('Heading commands', () => {
it('should correctly identify active heading level 1', () => {
const h1Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h1');
editor.commands.setContent('<h1>Test</h1>');
expect(h1Command?.getIsActive(editor)).toBe(true);
});
it('should correctly identify active heading level 2', () => {
const h2Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h2');
editor.commands.setContent('<h2>Test</h2>');
expect(h2Command?.getIsActive(editor)).toBe(true);
});
it('should correctly identify active heading level 3', () => {
const h3Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h3');
editor.commands.setContent('<h3>Test</h3>');
expect(h3Command?.getIsActive(editor)).toBe(true);
});
it('should not be active for different heading levels', () => {
const h1Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h1');
const h2Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h2');
editor.commands.setContent('<h1>Test</h1>');
expect(h1Command?.getIsActive(editor)).toBe(true);
expect(h2Command?.getIsActive(editor)).toBe(false);
});
it('should be visible when editor supports headings', () => {
const h1Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h1');
const h2Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h2');
const h3Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h3');
expect(h1Command?.getIsVisible(editor)).toBe(true);
expect(h2Command?.getIsVisible(editor)).toBe(true);
expect(h3Command?.getIsVisible(editor)).toBe(true);
});
});
describe('List commands', () => {
it('should correctly identify active bullet list', () => {
const bulletListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'bulletList',
);
editor.commands.setContent('<ul><li>Item</li></ul>');
editor.commands.focus();
expect(bulletListCommand?.getIsActive(editor)).toBe(true);
});
it('should correctly identify active ordered list', () => {
const orderedListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'orderedList',
);
editor.commands.setContent('<ol><li>Item</li></ol>');
editor.commands.focus();
expect(orderedListCommand?.getIsActive(editor)).toBe(true);
});
it('should not show bullet list when both are active', () => {
const bulletListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'bulletList',
);
const orderedListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'orderedList',
);
editor.commands.setContent('<ul><li>Item</li></ul>');
editor.commands.focus();
expect(bulletListCommand?.getIsActive(editor)).toBe(true);
expect(orderedListCommand?.getIsActive(editor)).toBe(false);
});
it('should be visible when editor supports lists', () => {
const bulletListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'bulletList',
);
const orderedListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'orderedList',
);
expect(bulletListCommand?.getIsVisible(editor)).toBe(true);
expect(orderedListCommand?.getIsVisible(editor)).toBe(true);
});
});
describe('Command execution', () => {
it('should execute paragraph command correctly', () => {
const paragraphCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'paragraph',
);
editor.commands.setContent('<h1>Test</h1>');
editor.commands.focus();
const range = { from: 0, to: 0 };
const onSelect = paragraphCommand?.getOnSelect(editor, range);
onSelect?.();
expect(editor.isActive('paragraph')).toBe(true);
});
it('should execute heading command and apply correct level', () => {
const h2Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h2');
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
const range = { from: 0, to: 0 };
const onSelect = h2Command?.getOnSelect(editor, range);
onSelect?.();
expect(editor.isActive('heading', { level: 2 })).toBe(true);
});
it('should execute list command correctly', () => {
const bulletListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'bulletList',
);
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
const range = { from: 0, to: 0 };
const onSelect = bulletListCommand?.getOnSelect(editor, range);
onSelect?.();
expect(editor.isActive('bulletList')).toBe(true);
});
});
describe('Search keywords', () => {
it('should include relevant keywords for each command', () => {
const paragraphCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'paragraph',
);
expect(paragraphCommand?.keywords.length).toBeGreaterThan(0);
});
it('should have heading keywords for heading commands', () => {
const h1Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h1');
// Should have keywords array
expect(h1Command?.keywords.length).toBeGreaterThan(0);
});
it('should have list keywords for list commands', () => {
const bulletListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'bulletList',
);
const orderedListCommand = DEFAULT_SLASH_COMMANDS.find(
(cmd) => cmd.id === 'orderedList',
);
expect(bulletListCommand?.keywords.length).toBeGreaterThan(0);
expect(orderedListCommand?.keywords.length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,148 @@
import { i18n } from '@lingui/core';
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Heading } from '@tiptap/extension-heading';
import { ListKit } from '@tiptap/extension-list';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { SlashCommand } from '../SlashCommand';
describe('SlashCommand', () => {
let editor: Editor;
beforeEach(() => {
// Initialize i18n for tests
i18n.load('en', {});
i18n.activate('en');
editor = new Editor({
extensions: [
Document,
Paragraph,
Text,
Heading.configure({ levels: [1, 2, 3] }),
ListKit,
SlashCommand,
],
content: '<p></p>',
});
});
afterEach(() => {
editor?.destroy();
});
describe('Slash command trigger', () => {
it('should trigger suggestions when typing /', () => {
editor.commands.setContent('<p></p>');
editor.commands.focus();
// Type the slash character
editor.commands.insertContent('/');
// The suggestion plugin should be active
// We verify by checking if typing continues to work
expect(editor.getText()).toBe('/');
});
it('should filter commands based on query', () => {
editor.commands.setContent('<p></p>');
editor.commands.focus();
// Type slash and partial command
editor.commands.insertContent('/head');
// Content should include the typed text
expect(editor.getText()).toContain('head');
});
});
describe('Command execution', () => {
it('should convert to heading 1 when command is executed', () => {
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
// Simulate selecting heading 1 command
editor.chain().focus().setHeading({ level: 1 }).run();
expect(editor.isActive('heading', { level: 1 })).toBe(true);
});
it('should convert to heading 2 when command is executed', () => {
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
editor.chain().focus().setHeading({ level: 2 }).run();
expect(editor.isActive('heading', { level: 2 })).toBe(true);
});
it('should convert to heading 3 when command is executed', () => {
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
editor.chain().focus().setHeading({ level: 3 }).run();
expect(editor.isActive('heading', { level: 3 })).toBe(true);
});
it('should convert to bullet list when command is executed', () => {
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
editor.chain().focus().toggleBulletList().run();
expect(editor.isActive('bulletList')).toBe(true);
});
it('should convert to ordered list when command is executed', () => {
editor.commands.setContent('<p>Test</p>');
editor.commands.focus();
editor.chain().focus().toggleOrderedList().run();
expect(editor.isActive('orderedList')).toBe(true);
});
it('should convert back to paragraph when command is executed', () => {
editor.commands.setContent('<h1>Test</h1>');
editor.commands.focus();
editor.chain().focus().setParagraph().run();
expect(editor.isActive('paragraph')).toBe(true);
expect(editor.isActive('heading')).toBe(false);
});
});
describe('Command availability', () => {
it('should allow setting headings when heading extension is present', () => {
// Editor with heading extension should be able to set headings
expect(editor.can().setHeading({ level: 1 })).toBe(true);
expect(editor.can().setHeading({ level: 2 })).toBe(true);
expect(editor.can().setHeading({ level: 3 })).toBe(true);
});
it('should allow toggling lists when list extension is present', () => {
// Editor with list extension should be able to toggle lists
expect(editor.can().toggleBulletList()).toBe(true);
expect(editor.can().toggleOrderedList()).toBe(true);
});
});
describe('Read-only mode', () => {
it('should not be editable in read-only mode', () => {
const readonlyEditor = new Editor({
extensions: [Document, Paragraph, Text, Heading, SlashCommand],
content: '<p>Test</p>',
editable: false,
});
// Verify editor is not editable
expect(readonlyEditor.isEditable).toBe(false);
readonlyEditor.destroy();
});
});
});
@@ -0,0 +1,327 @@
import { Editor } from '@tiptap/core';
import { Document } from '@tiptap/extension-document';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Text } from '@tiptap/extension-text';
import { type SlashCommandItem } from '../SlashCommand';
import { SlashCommandRenderer } from '../SlashCommandRenderer';
describe('SlashCommandRenderer', () => {
let editor: Editor;
let mockCommand: jest.Mock;
let mockClientRect: () => DOMRect;
const createMockItems = (): SlashCommandItem[] => [
{
id: 'test-1',
title: 'Test Command 1',
description: 'Test description',
command: jest.fn(),
},
{
id: 'test-2',
title: 'Test Command 2',
command: jest.fn(),
},
];
beforeEach(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text],
content: '<p></p>',
});
mockCommand = jest.fn();
mockClientRect = () => new DOMRect(100, 100, 200, 50);
});
afterEach(() => {
editor?.destroy();
});
describe('Lifecycle management', () => {
it('should create container element and append to body', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.containerElement).toBeInstanceOf(HTMLElement);
expect(document.body.contains(renderer.containerElement)).toBe(true);
renderer.destroy();
});
it('should create React root on initialization', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.componentRoot).not.toBeNull();
renderer.destroy();
});
it('should clean up on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const containerElement = renderer.containerElement;
renderer.destroy();
// After destroy, references should be null
expect(renderer.componentRoot).toBeNull();
expect(renderer.containerElement).toBeNull();
expect(renderer.currentProps).toBeNull();
expect(renderer.ref).toBeNull();
// Container should be removed from DOM
expect(document.body.contains(containerElement)).toBe(false);
});
it('should handle multiple destroy calls gracefully', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// First destroy
renderer.destroy();
// Second destroy should not throw
expect(() => renderer.destroy()).not.toThrow();
});
});
describe('Props updates', () => {
it('should update items when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
const newItems: SlashCommandItem[] = [
{
id: 'new-item',
title: 'New Item',
command: jest.fn(),
},
];
renderer.updateProps({ items: newItems });
expect(renderer.currentProps?.items).toEqual(newItems);
renderer.destroy();
});
it('should update query when updateProps is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.updateProps({ query: 'heading' });
expect(renderer.currentProps?.query).toBe('heading');
renderer.destroy();
});
it('should not update props if component is destroyed', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
// After destroy, updateProps should not throw
expect(() => renderer.updateProps({ query: 'test' })).not.toThrow();
});
it('should preserve unmodified props when updating', () => {
const originalItems = createMockItems();
const renderer = new SlashCommandRenderer({
items: originalItems,
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: 'original',
});
// Update only query
renderer.updateProps({ query: 'updated' });
// Items should remain the same
expect(renderer.currentProps?.items).toEqual(originalItems);
// Query should be updated
expect(renderer.currentProps?.query).toBe('updated');
renderer.destroy();
});
});
describe('Render method', () => {
it('should not throw when render is called', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
renderer.destroy();
});
it('should not render if component root is null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Destroy to set componentRoot to null
renderer.destroy();
// Render should not throw even with null componentRoot
expect(() =>
renderer.render({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
}),
).not.toThrow();
});
});
describe('Ref handling', () => {
it('should initialize ref as null', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
expect(renderer.ref).toBeNull();
renderer.destroy();
});
it('should clear ref on destroy', () => {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Simulate ref being set
renderer.ref = { onKeyDown: jest.fn() };
renderer.destroy();
expect(renderer.ref).toBeNull();
});
});
describe('Memory management', () => {
it('should not leak DOM elements after destroy', () => {
const initialBodyChildCount = document.body.childElementCount;
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
// Should have added one element
expect(document.body.childElementCount).toBe(initialBodyChildCount + 1);
renderer.destroy();
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
it('should handle rapid create/destroy cycles', () => {
const initialBodyChildCount = document.body.childElementCount;
// Create and destroy multiple renderers rapidly
for (let i = 0; i < 10; i++) {
const renderer = new SlashCommandRenderer({
items: createMockItems(),
command: mockCommand,
clientRect: mockClientRect,
editor,
range: { from: 0, to: 0 },
query: '',
});
renderer.destroy();
}
// Should be back to initial count
expect(document.body.childElementCount).toBe(initialBodyChildCount);
});
});
});
@@ -1,7 +1,9 @@
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 { getInitialAdvancedTextEditorContent } from '@/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import { t } from '@lingui/core/macro';
import { Bold } from '@tiptap/extension-bold';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
@@ -27,6 +29,7 @@ type UseAdvancedTextEditorProps = {
onBlur?: (editor: Editor) => void;
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
enableSlashCommand?: boolean;
};
export const useAdvancedTextEditor = (
@@ -39,6 +42,7 @@ export const useAdvancedTextEditor = (
onBlur,
onImageUpload,
onImageUploadError,
enableSlashCommand,
}: UseAdvancedTextEditorProps,
dependencies?: DependencyList,
) => {
@@ -48,7 +52,7 @@ export const useAdvancedTextEditor = (
Paragraph,
Text,
Placeholder.configure({
placeholder,
placeholder: placeholder ?? t`Enter text or Type '/' for commands`,
}),
VariableTag,
HardBreak.configure({
@@ -72,8 +76,15 @@ export const useAdvancedTextEditor = (
onImageUpload,
onImageUploadError,
}),
...(!readonly && enableSlashCommand !== false ? [SlashCommand] : []),
],
[
placeholder,
onImageUpload,
onImageUploadError,
readonly,
enableSlashCommand,
],
[placeholder, onImageUpload, onImageUploadError],
);
const editor = useEditor(
@@ -116,7 +116,7 @@ export const FormAdvancedTextFieldInput = ({
const editor = useAdvancedTextEditor(
{
placeholder: placeholder ?? 'Enter text',
placeholder: placeholder,
readonly,
defaultValue,
onUpdate: (editor) => {
@@ -140,6 +140,7 @@ export const FormAdvancedTextFieldInput = ({
},
onImageUpload,
onImageUploadError,
enableSlashCommand: true,
},
[isFullScreen],
);
@@ -333,7 +333,6 @@ export const WorkflowEditActionSendEmail = ({
/>
<FormAdvancedTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={formData.body}
onChange={(body: string) => {
+11
View File
@@ -23146,6 +23146,16 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/suggestion@npm:^3.4.2":
version: 3.9.0
resolution: "@tiptap/suggestion@npm:3.9.0"
peerDependencies:
"@tiptap/core": ^3.9.0
"@tiptap/pm": ^3.9.0
checksum: 10c0/40e54124cc65c8f0f752c9cee393070fb4f427aaf83b1f5a5c2f55dce32ed9bd5d39b94bf9825c0ed8f22f87116e6a9b788c83ddf6bafb2895cd46d061554c11
languageName: node
linkType: hard
"@toeverything/y-indexeddb@npm:^0.10.0-canary.9":
version: 0.10.0-canary.9
resolution: "@toeverything/y-indexeddb@npm:0.10.0-canary.9"
@@ -55754,6 +55764,7 @@ __metadata:
"@tiptap/extension-underline": "npm:^3.4.2"
"@tiptap/extensions": "npm:^3.4.2"
"@tiptap/react": "npm:^3.4.2"
"@tiptap/suggestion": "npm:^3.4.2"
"@types/apollo-upload-client": "npm:^17.0.2"
"@types/file-saver": "npm:^2.0.7"
"@types/js-cookie": "npm:^3.0.3"