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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
@@ -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) {
|
||||
|
||||
+93
@@ -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(),
|
||||
},
|
||||
];
|
||||
+168
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
+220
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
+284
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+148
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+327
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-2
@@ -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(
|
||||
|
||||
+2
-1
@@ -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],
|
||||
);
|
||||
|
||||
-1
@@ -333,7 +333,6 @@ export const WorkflowEditActionSendEmail = ({
|
||||
/>
|
||||
<FormAdvancedTextFieldInput
|
||||
label="Body"
|
||||
placeholder="Enter email body"
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.body}
|
||||
onChange={(body: string) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user