From fae2c4fc24d1779125b21d4e786ff416eefb8991 Mon Sep 17 00:00:00 2001
From: Steve Larson <9larsons@gmail.com>
Date: Tue, 13 Jan 2026 14:55:36 -0600
Subject: [PATCH 1/3] Added slash menu and cards to email editor
ref https://linear.app/ghost/issue/NY-914/
---
packages/koenig-lexical/demo/DemoApp.jsx | 13 +++-
.../koenig-lexical/src/nodes/EmailNodes.js | 8 +-
.../test/e2e/editors/email-editor.test.js | 78 +++++++++++++++++--
3 files changed, 89 insertions(+), 10 deletions(-)
diff --git a/packages/koenig-lexical/demo/DemoApp.jsx b/packages/koenig-lexical/demo/DemoApp.jsx
index 382289fd0..176142fc6 100644
--- a/packages/koenig-lexical/demo/DemoApp.jsx
+++ b/packages/koenig-lexical/demo/DemoApp.jsx
@@ -5,7 +5,6 @@ import FloatingButton from './components/FloatingButton';
import InitialContentToggle from './components/InitialContentToggle';
import LockIcon from './assets/icons/kg-lock.svg?react';
import React, {useState} from 'react';
-import ReplacementStringsPlugin from '../src/plugins/ReplacementStringsPlugin';
import Sidebar from './components/Sidebar';
import TitleTextBox from './components/TitleTextBox';
import Watermark from './components/Watermark';
@@ -20,6 +19,13 @@ import {
KoenigComposableEditor, KoenigComposer, KoenigEditor, ListPlugin, MINIMAL_NODES,
MINIMAL_TRANSFORMERS, RestrictContentPlugin, TKCountPlugin, WordCountPlugin
} from '../src';
+// These plugins must be imported AFTER '../src' to avoid circular dependency issues
+import CalloutPlugin from '../src/plugins/CalloutPlugin';
+import HorizontalRulePlugin from '../src/plugins/HorizontalRulePlugin';
+import ImagePlugin from '../src/plugins/ImagePlugin';
+import ReplacementStringsPlugin from '../src/plugins/ReplacementStringsPlugin';
+import SlashCardMenuPlugin from '../src/plugins/SlashCardMenuPlugin';
+import {ButtonPlugin} from '../src/plugins/ButtonPlugin';
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fetchEmbed} from './utils/fetchEmbed';
import {fileTypes, useFileUpload} from './utils/useFileUpload';
@@ -169,6 +175,11 @@ function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setW
+
+
+
+
+
);
}
diff --git a/packages/koenig-lexical/src/nodes/EmailNodes.js b/packages/koenig-lexical/src/nodes/EmailNodes.js
index 1f1d0126b..f665d7c3d 100644
--- a/packages/koenig-lexical/src/nodes/EmailNodes.js
+++ b/packages/koenig-lexical/src/nodes/EmailNodes.js
@@ -10,7 +10,10 @@ import {LinkNode} from '@lexical/link';
import {ListItemNode, ListNode} from '@lexical/list';
import {AsideNode} from './AsideNode';
+import {ButtonNode} from './ButtonNode';
+import {CalloutNode} from './CalloutNode';
import {HorizontalRuleNode} from './HorizontalRuleNode';
+import {ImageNode} from './ImageNode';
const EMAIL_NODES = [
ExtendedTextNode,
@@ -23,7 +26,10 @@ const EMAIL_NODES = [
ListNode,
ListItemNode,
LinkNode,
- HorizontalRuleNode
+ ButtonNode,
+ CalloutNode,
+ HorizontalRuleNode,
+ ImageNode
];
export default EMAIL_NODES;
diff --git a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js b/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
index c7b233ce0..1bf0dbf37 100644
--- a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
+++ b/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
@@ -93,7 +93,7 @@ test.describe('Koenig Editor with email template nodes', async function () {
test('can create horizontal rules with --- shortcut', async function () {
await focusEditor(page);
- await page.keyboard.type('--- ');
+ await page.keyboard.type('---');
await assertHTML(page, html`
@@ -127,6 +127,75 @@ test.describe('Koenig Editor with email template nodes', async function () {
});
});
+ test.describe('Slash menu', function () {
+ test('opens on / keystroke', async function () {
+ await focusEditor(page);
+ await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
+ await page.keyboard.type('/');
+ await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
+ });
+
+ test('shows button card option', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/button');
+ await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
+ await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Button'})).toBeVisible();
+ });
+
+ test('shows divider option', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/hr');
+ await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
+ await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Divider'})).toBeVisible();
+ });
+
+ test('shows image option', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/image');
+ await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
+ await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Image'})).toBeVisible();
+ });
+
+ test('shows callout option', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/callout');
+ await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
+ await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Callout'})).toBeVisible();
+ });
+
+ test('can insert button card', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/button');
+ await page.keyboard.press('Enter');
+
+ await expect(page.locator('[data-kg-card="button"]')).toBeVisible();
+ });
+
+ test('can insert divider card', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/hr');
+ await page.keyboard.press('Enter');
+
+ await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible();
+ });
+
+ test('can insert image card', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/image');
+ await page.keyboard.press('Enter');
+
+ await expect(page.locator('[data-kg-card="image"]')).toBeVisible();
+ });
+
+ test('can insert callout card', async function () {
+ await focusEditor(page);
+ await page.keyboard.type('/callout');
+ await page.keyboard.press('Enter');
+
+ await expect(page.locator('[data-kg-card="callout"]')).toBeVisible();
+ });
+ });
+
test.describe('Unsupported features', function () {
test('code block shortcut does NOT create code block', async function () {
await focusEditor(page);
@@ -138,13 +207,6 @@ test.describe('Koenig Editor with email template nodes', async function () {
`);
});
- test('slash menu is not available', async function () {
- await focusEditor(page);
- await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
- await page.keyboard.type('/');
- await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
- });
-
test('plus button is not shown', async function () {
await focusEditor(page);
await expect(page.locator('[data-kg-plus-button]')).toHaveCount(0);
From bb7b9b856746a4c22b55f58c74e6412288db94a6 Mon Sep 17 00:00:00 2001
From: Steve Larson <9larsons@gmail.com>
Date: Tue, 13 Jan 2026 15:07:33 -0600
Subject: [PATCH 2/3] Updated email editor to a component
---
packages/koenig-lexical/demo/DemoApp.jsx | 27 ++++----------
.../src/components/KoenigEmailEditor.jsx | 35 +++++++++++++++++++
packages/koenig-lexical/src/index.js | 2 ++
3 files changed, 43 insertions(+), 21 deletions(-)
create mode 100644 packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
diff --git a/packages/koenig-lexical/demo/DemoApp.jsx b/packages/koenig-lexical/demo/DemoApp.jsx
index 176142fc6..fec670ecb 100644
--- a/packages/koenig-lexical/demo/DemoApp.jsx
+++ b/packages/koenig-lexical/demo/DemoApp.jsx
@@ -15,17 +15,11 @@ import emailContent from './content/email-content.json';
import minimalContent from './content/minimal-content.json';
import {$getRoot, $isDecoratorNode} from 'lexical';
import {
- BASIC_NODES, BASIC_TRANSFORMERS, EMAIL_NODES, EMAIL_TRANSFORMERS,
- KoenigComposableEditor, KoenigComposer, KoenigEditor, ListPlugin, MINIMAL_NODES,
- MINIMAL_TRANSFORMERS, RestrictContentPlugin, TKCountPlugin, WordCountPlugin
+ BASIC_NODES, BASIC_TRANSFORMERS, EMAIL_NODES,
+ KoenigComposableEditor, KoenigComposer, KoenigEditor, KoenigEmailEditor,
+ MINIMAL_NODES, MINIMAL_TRANSFORMERS, RestrictContentPlugin,
+ TKCountPlugin, WordCountPlugin
} from '../src';
-// These plugins must be imported AFTER '../src' to avoid circular dependency issues
-import CalloutPlugin from '../src/plugins/CalloutPlugin';
-import HorizontalRulePlugin from '../src/plugins/HorizontalRulePlugin';
-import ImagePlugin from '../src/plugins/ImagePlugin';
-import ReplacementStringsPlugin from '../src/plugins/ReplacementStringsPlugin';
-import SlashCardMenuPlugin from '../src/plugins/SlashCardMenuPlugin';
-import {ButtonPlugin} from '../src/plugins/ButtonPlugin';
import {defaultHeaders as defaultUnsplashHeaders} from './utils/unsplashConfig';
import {fetchEmbed} from './utils/fetchEmbed';
import {fileTypes, useFileUpload} from './utils/useFileUpload';
@@ -165,22 +159,13 @@ function DemoEditor({editorType, registerAPI, cursorDidExitAtTop, darkMode, setW
);
} else if (editorType === 'email') {
return (
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx b/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
new file mode 100644
index 000000000..3ce9d3d1e
--- /dev/null
+++ b/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
@@ -0,0 +1,35 @@
+import '../styles/index.css';
+import HorizontalRulePlugin from '../plugins/HorizontalRulePlugin';
+import ImagePlugin from '../plugins/ImagePlugin';
+import KoenigComposableEditor from './KoenigComposableEditor';
+import React from 'react';
+import ReplacementStringsPlugin from '../plugins/ReplacementStringsPlugin';
+import SlashCardMenuPlugin from '../plugins/SlashCardMenuPlugin';
+import {ButtonPlugin} from '../plugins/ButtonPlugin';
+import {CalloutPlugin} from '../plugins/CalloutPlugin';
+import {EMAIL_TRANSFORMERS} from '../plugins/MarkdownShortcutPlugin';
+import {ListPlugin} from '@lexical/react/LexicalListPlugin';
+
+const KoenigEmailEditor = ({
+ children,
+ ...props
+}) => {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+ );
+};
+
+export default KoenigEmailEditor;
diff --git a/packages/koenig-lexical/src/index.js b/packages/koenig-lexical/src/index.js
index f5ee8ec65..a30fec012 100644
--- a/packages/koenig-lexical/src/index.js
+++ b/packages/koenig-lexical/src/index.js
@@ -4,6 +4,7 @@ import KoenigCardWrapper from './components/KoenigCardWrapper';
import KoenigComposableEditor from './components/KoenigComposableEditor';
import KoenigComposer from './components/KoenigComposer';
import KoenigEditor from './components/KoenigEditor';
+import KoenigEmailEditor from './components/KoenigEmailEditor';
import KoenigNestedComposer from './components/KoenigNestedComposer';
/* Plugins */
@@ -64,6 +65,7 @@ export {
KoenigComposableEditor,
KoenigComposer,
KoenigEditor,
+ KoenigEmailEditor,
KoenigNestedComposer,
KoenigCardWrapper,
From 0f0b138d84e1b41d16c5e16538ba3994e6e6c3db Mon Sep 17 00:00:00 2001
From: Chris Raible
Date: Wed, 14 Jan 2026 14:03:32 -0800
Subject: [PATCH 3/3] Removed Slash menu and image nodes
---
.../src/components/KoenigEmailEditor.jsx | 4 -
.../koenig-lexical/src/nodes/EmailNodes.js | 4 +-
.../test/e2e/editors/email-editor.test.js | 76 ++-----------------
3 files changed, 8 insertions(+), 76 deletions(-)
diff --git a/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx b/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
index 3ce9d3d1e..a3eeaffe0 100644
--- a/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
+++ b/packages/koenig-lexical/src/components/KoenigEmailEditor.jsx
@@ -1,10 +1,8 @@
import '../styles/index.css';
import HorizontalRulePlugin from '../plugins/HorizontalRulePlugin';
-import ImagePlugin from '../plugins/ImagePlugin';
import KoenigComposableEditor from './KoenigComposableEditor';
import React from 'react';
import ReplacementStringsPlugin from '../plugins/ReplacementStringsPlugin';
-import SlashCardMenuPlugin from '../plugins/SlashCardMenuPlugin';
import {ButtonPlugin} from '../plugins/ButtonPlugin';
import {CalloutPlugin} from '../plugins/CalloutPlugin';
import {EMAIL_TRANSFORMERS} from '../plugins/MarkdownShortcutPlugin';
@@ -22,11 +20,9 @@ const KoenigEmailEditor = ({
>
-
-
{children}
);
diff --git a/packages/koenig-lexical/src/nodes/EmailNodes.js b/packages/koenig-lexical/src/nodes/EmailNodes.js
index f665d7c3d..e9f7d6708 100644
--- a/packages/koenig-lexical/src/nodes/EmailNodes.js
+++ b/packages/koenig-lexical/src/nodes/EmailNodes.js
@@ -13,7 +13,6 @@ import {AsideNode} from './AsideNode';
import {ButtonNode} from './ButtonNode';
import {CalloutNode} from './CalloutNode';
import {HorizontalRuleNode} from './HorizontalRuleNode';
-import {ImageNode} from './ImageNode';
const EMAIL_NODES = [
ExtendedTextNode,
@@ -28,8 +27,7 @@ const EMAIL_NODES = [
LinkNode,
ButtonNode,
CalloutNode,
- HorizontalRuleNode,
- ImageNode
+ HorizontalRuleNode
];
export default EMAIL_NODES;
diff --git a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js b/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
index 1bf0dbf37..c0e811989 100644
--- a/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
+++ b/packages/koenig-lexical/test/e2e/editors/email-editor.test.js
@@ -127,75 +127,6 @@ test.describe('Koenig Editor with email template nodes', async function () {
});
});
- test.describe('Slash menu', function () {
- test('opens on / keystroke', async function () {
- await focusEditor(page);
- await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
- await page.keyboard.type('/');
- await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
- });
-
- test('shows button card option', async function () {
- await focusEditor(page);
- await page.keyboard.type('/button');
- await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
- await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Button'})).toBeVisible();
- });
-
- test('shows divider option', async function () {
- await focusEditor(page);
- await page.keyboard.type('/hr');
- await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
- await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Divider'})).toBeVisible();
- });
-
- test('shows image option', async function () {
- await focusEditor(page);
- await page.keyboard.type('/image');
- await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
- await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Image'})).toBeVisible();
- });
-
- test('shows callout option', async function () {
- await focusEditor(page);
- await page.keyboard.type('/callout');
- await expect(page.locator('[data-kg-slash-menu]')).toBeVisible();
- await expect(page.locator('[data-kg-cardmenu-idx]', {hasText: 'Callout'})).toBeVisible();
- });
-
- test('can insert button card', async function () {
- await focusEditor(page);
- await page.keyboard.type('/button');
- await page.keyboard.press('Enter');
-
- await expect(page.locator('[data-kg-card="button"]')).toBeVisible();
- });
-
- test('can insert divider card', async function () {
- await focusEditor(page);
- await page.keyboard.type('/hr');
- await page.keyboard.press('Enter');
-
- await expect(page.locator('[data-kg-card="horizontalrule"]')).toBeVisible();
- });
-
- test('can insert image card', async function () {
- await focusEditor(page);
- await page.keyboard.type('/image');
- await page.keyboard.press('Enter');
-
- await expect(page.locator('[data-kg-card="image"]')).toBeVisible();
- });
-
- test('can insert callout card', async function () {
- await focusEditor(page);
- await page.keyboard.type('/callout');
- await page.keyboard.press('Enter');
-
- await expect(page.locator('[data-kg-card="callout"]')).toBeVisible();
- });
- });
-
test.describe('Unsupported features', function () {
test('code block shortcut does NOT create code block', async function () {
await focusEditor(page);
@@ -207,6 +138,13 @@ test.describe('Koenig Editor with email template nodes', async function () {
`);
});
+ test('slash menu is not available', async function () {
+ await focusEditor(page);
+ await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
+ await page.keyboard.type('/');
+ await expect(page.locator('[data-kg-slash-menu]')).toHaveCount(0);
+ });
+
test('plus button is not shown', async function () {
await focusEditor(page);
await expect(page.locator('[data-kg-plus-button]')).toHaveCount(0);