@@ -107,3 +118,27 @@ function getCardClasses(node) {
return cardClasses.join(' ');
}
+
+function getButtonClasses(node) {
+ const buttonClasses = ['kg-btn'];
+
+ if (node.buttonColor === 'accent' || !node.buttonColor) {
+ buttonClasses.push('kg-btn-accent');
+ }
+
+ return buttonClasses.join(' ');
+}
+
+function getButtonStyle(node) {
+ const styles = [];
+
+ if (node.buttonColor && node.buttonColor !== 'accent') {
+ styles.push(`background-color: ${node.buttonColor}`);
+ }
+
+ if (node.buttonTextColor && (node.buttonColor !== 'accent' || node.buttonTextColor !== '#ffffff')) {
+ styles.push(`color: ${node.buttonTextColor}`);
+ }
+
+ return styles.join('; ');
+}
diff --git a/packages/kg-default-nodes/lib/utils/render-helpers/email-button.js b/packages/kg-default-nodes/lib/utils/render-helpers/email-button.js
index 45f34c7870..ec6750c863 100644
--- a/packages/kg-default-nodes/lib/utils/render-helpers/email-button.js
+++ b/packages/kg-default-nodes/lib/utils/render-helpers/email-button.js
@@ -6,7 +6,7 @@ import {html} from '../tagged-template-fns.mjs';
* @param {string} [options.alignment]
* @param {string} [options.color='accent']
* @param {string} [options.text='']
- * @param {string} [options.textColor]
+ * @param {string} [options.textColor='#ffffff']
* @param {string} [options.url='']
* @returns {string}
*/
@@ -14,22 +14,25 @@ export function renderEmailButton({
alignment = '',
color = 'accent',
text = '',
+ textColor = '#ffffff',
url = ''
} = {}) {
const buttonClasses = clsx(
'btn',
color === 'accent' && 'btn-accent'
);
+ const buttonStyle = color !== 'accent' ? ` style="background-color: ${color};"` : '';
+ const textStyle = (textColor && (color !== 'accent' || textColor !== '#ffffff')) ? ` style="color: ${textColor};"` : '';
return html`
`;
-}
\ No newline at end of file
+}
diff --git a/packages/kg-default-nodes/test/nodes/button.test.js b/packages/kg-default-nodes/test/nodes/button.test.js
index 6354f4a8bf..8f66361912 100644
--- a/packages/kg-default-nodes/test/nodes/button.test.js
+++ b/packages/kg-default-nodes/test/nodes/button.test.js
@@ -31,7 +31,9 @@ describe('ButtonNode', function () {
dataset = {
buttonText: 'click me',
buttonUrl: 'http://blog.com/post1',
- alignment: 'center'
+ alignment: 'center',
+ buttonColor: 'accent',
+ buttonTextColor: '#ffffff'
};
exportOptions = {
dom
@@ -50,6 +52,8 @@ describe('ButtonNode', function () {
buttonNode.buttonUrl.should.equal(dataset.buttonUrl);
buttonNode.buttonText.should.equal(dataset.buttonText);
buttonNode.alignment.should.equal(dataset.alignment);
+ buttonNode.buttonColor.should.equal(dataset.buttonColor);
+ buttonNode.buttonTextColor.should.equal(dataset.buttonTextColor);
}));
it('has setters for all properties', editorTest(function () {
@@ -66,6 +70,14 @@ describe('ButtonNode', function () {
buttonNode.alignment.should.equal('center');
buttonNode.alignment = 'left';
buttonNode.alignment.should.equal('left');
+
+ buttonNode.buttonColor.should.equal('accent');
+ buttonNode.buttonColor = '#ff0000';
+ buttonNode.buttonColor.should.equal('#ff0000');
+
+ buttonNode.buttonTextColor.should.equal('#ffffff');
+ buttonNode.buttonTextColor = '#000000';
+ buttonNode.buttonTextColor.should.equal('#000000');
}));
it('has getDataset() convenience method', editorTest(function () {
@@ -132,6 +144,18 @@ describe('ButtonNode', function () {
output.should.containEql('
');
}));
+ it('renders custom button colors', editorTest(function () {
+ const customDataset = {
+ ...dataset,
+ buttonColor: '#ff0000',
+ buttonTextColor: '#000000'
+ };
+ const buttonNode = $createButtonNode(customDataset);
+ const {element} = buttonNode.exportDOM(exportOptions);
+
+ element.outerHTML.should.prettifyTo(html``);
+ }));
+
it('renders for email target (emailCustomization)', editorTest(function () {
const buttonNode = $createButtonNode(dataset);
const options = {
@@ -196,6 +220,43 @@ describe('ButtonNode', function () {
`);
}));
+ it('renders custom colors for email targets', editorTest(function () {
+ const customDataset = {
+ ...dataset,
+ buttonColor: '#ff0000',
+ buttonTextColor: '#000000'
+ };
+ const buttonNode = $createButtonNode(customDataset);
+ const options = {
+ target: 'email',
+ feature: {
+ emailCustomization: true
+ }
+ };
+ const {element} = buttonNode.exportDOM({...exportOptions, ...options});
+ const output = element.innerHTML;
+
+ output.should.prettifyTo(html`
+
+ `);
+ }));
+
it('renders an empty span with a missing buttonUrl', editorTest(function () {
const buttonNode = $createButtonNode();
const {element} = buttonNode.exportDOM(exportOptions);
@@ -214,7 +275,9 @@ describe('ButtonNode', function () {
version: 1,
buttonUrl: dataset.buttonUrl,
buttonText: dataset.buttonText,
- alignment: dataset.alignment
+ alignment: dataset.alignment,
+ buttonColor: dataset.buttonColor,
+ buttonTextColor: dataset.buttonTextColor
});
}));
});
@@ -245,6 +308,8 @@ describe('ButtonNode', function () {
buttonNode.buttonUrl.should.equal(dataset.buttonUrl);
buttonNode.buttonText.should.equal(dataset.buttonText);
buttonNode.alignment.should.equal(dataset.alignment);
+ buttonNode.buttonColor.should.equal(dataset.buttonColor);
+ buttonNode.buttonTextColor.should.equal(dataset.buttonTextColor);
done();
} catch (e) {
@@ -276,6 +341,8 @@ describe('ButtonNode', function () {
nodes[0].buttonUrl.should.equal('http://someblog.com/somepost');
nodes[0].buttonText.should.equal('click me');
nodes[0].alignment.should.equal('center');
+ nodes[0].buttonColor.should.equal('accent');
+ nodes[0].buttonTextColor.should.equal('#ffffff');
}));
it('preserves relative urls in content', editorTest(function () {
@@ -289,6 +356,23 @@ describe('ButtonNode', function () {
nodes[0].buttonUrl.should.equal('#/portal/signup');
nodes[0].buttonText.should.equal('Subscribe 1');
nodes[0].alignment.should.equal('center');
+ nodes[0].buttonColor.should.equal('accent');
+ nodes[0].buttonTextColor.should.equal('#ffffff');
+ }));
+
+ it('parses custom button colors', editorTest(function () {
+ const document = createDocument(html`
+
+ `);
+ const nodes = $generateNodesFromDOM(editor, document);
+ nodes.length.should.equal(1);
+ nodes[0].buttonUrl.should.equal('http://someblog.com/somepost');
+ nodes[0].buttonText.should.equal('Subscribe 1');
+ nodes[0].alignment.should.equal('left');
+ nodes[0].buttonColor.should.equal('#ff0000');
+ nodes[0].buttonTextColor.should.equal('#000000');
}));
});
diff --git a/packages/koenig-lexical/src/components/KoenigComposableEditor.jsx b/packages/koenig-lexical/src/components/KoenigComposableEditor.jsx
index 3d6f15de05..fb0835bdf4 100644
--- a/packages/koenig-lexical/src/components/KoenigComposableEditor.jsx
+++ b/packages/koenig-lexical/src/components/KoenigComposableEditor.jsx
@@ -23,6 +23,7 @@ import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContex
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useSharedHistoryContext} from '../context/SharedHistoryContext';
import {useSharedOnChangeContext} from '../context/SharedOnChangeContext';
+import {isMobileViewport} from '../utils/isMobileViewport';
const KoenigComposableEditor = ({
onChange,
@@ -52,6 +53,37 @@ const KoenigComposableEditor = ({
const isNested = !!editor._parentEditor;
const isDragReorderEnabled = isDragEnabled && !readOnly && !isNested;
+ const [showMobileSlashHint, setShowMobileSlashHint] = React.useState(false);
+
+ React.useEffect(() => {
+ if (isNested) {
+ setShowMobileSlashHint(false);
+ return;
+ }
+
+ const updateViewportState = () => {
+ setShowMobileSlashHint(isMobileViewport());
+ };
+
+ updateViewportState();
+ window.addEventListener('resize', updateViewportState);
+
+ return () => {
+ window.removeEventListener('resize', updateViewportState);
+ };
+ }, [isNested]);
+
+ const resolvedPlaceholderText = React.useMemo(() => {
+ if (typeof placeholderText === 'string') {
+ return placeholderText;
+ }
+
+ if (showMobileSlashHint) {
+ return 'Begin writing your post... Type / to open the card menu.';
+ }
+
+ return undefined;
+ }, [placeholderText, showMobileSlashHint]);
const {onChange: sharedOnChange} = useSharedOnChangeContext();
const _onChange = React.useCallback((editorState) => {
@@ -101,7 +133,7 @@ const KoenigComposableEditor = ({
}
ErrorBoundary={KoenigErrorBoundary}
- placeholder={placeholder || }
+ placeholder={placeholder || }
/>
diff --git a/packages/koenig-lexical/src/components/ui/cards/ButtonCard.jsx b/packages/koenig-lexical/src/components/ui/cards/ButtonCard.jsx
index 6a0a7cc670..c3449f8a44 100644
--- a/packages/koenig-lexical/src/components/ui/cards/ButtonCard.jsx
+++ b/packages/koenig-lexical/src/components/ui/cards/ButtonCard.jsx
@@ -1,21 +1,27 @@
import CenterAlignIcon from '../../../assets/icons/kg-align-center.svg?react';
import LeftAlignIcon from '../../../assets/icons/kg-align-left.svg?react';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, {useState} from 'react';
import {Button} from '../Button';
-import {ButtonGroupSetting, InputSetting, InputUrlSetting, SettingsPanel} from '../SettingsPanel';
+import {ButtonGroupSetting, ColorPickerSetting, InputSetting, InputUrlSetting, SettingsPanel} from '../SettingsPanel';
import {ReadOnlyOverlay} from '../ReadOnlyOverlay';
+import {getAccentColor} from '../../../utils/getAccentColor';
+import {textColorForBackgroundColor} from '@tryghost/color-utils';
export function ButtonCard({
alignment,
+ buttonColor,
buttonText,
+ buttonTextColor,
buttonPlaceholder,
buttonUrl,
handleAlignmentChange,
+ handleButtonColorChange,
handleButtonTextChange,
handleButtonUrlChange,
isEditing
}) {
+ const [buttonColorPickerExpanded, setButtonColorPickerExpanded] = useState(false);
const buttonGroupChildren = [
{
label: 'Left',
@@ -31,11 +37,35 @@ export function ButtonCard({
}
];
+ const hexColorValue = (color) => {
+ if (!color) {
+ return '';
+ }
+ if (color === 'accent') {
+ return getAccentColor().trim();
+ }
+ return color.trim();
+ };
+
+ const matchingTextColor = (color) => {
+ return textColorForBackgroundColor(hexColorValue(color)).hex();
+ };
+
return (
<>
@@ -60,6 +90,24 @@ export function ButtonCard({
value={buttonUrl}
onChange={handleButtonUrlChange}
/>
+ handleButtonColorChange(color, matchingTextColor(color))}
+ onSwatchChange={(color) => {
+ handleButtonColorChange(color, matchingTextColor(color));
+ setButtonColorPickerExpanded(false);
+ }}
+ onTogglePicker={setButtonColorPickerExpanded}
+ />
)}
>
@@ -68,10 +116,13 @@ export function ButtonCard({
ButtonCard.propTypes = {
alignment: PropTypes.string,
+ buttonColor: PropTypes.string,
buttonText: PropTypes.string,
+ buttonTextColor: PropTypes.string,
buttonPlaceholder: PropTypes.string,
buttonUrl: PropTypes.string,
handleAlignmentChange: PropTypes.func,
+ handleButtonColorChange: PropTypes.func,
handleButtonTextChange: PropTypes.func,
handleButtonUrlChange: PropTypes.func,
handleButtonUrlFocus: PropTypes.func,
diff --git a/packages/koenig-lexical/src/components/ui/cards/ButtonCard.stories.jsx b/packages/koenig-lexical/src/components/ui/cards/ButtonCard.stories.jsx
index cb3f877905..766c917fa6 100644
--- a/packages/koenig-lexical/src/components/ui/cards/ButtonCard.stories.jsx
+++ b/packages/koenig-lexical/src/components/ui/cards/ButtonCard.stories.jsx
@@ -53,7 +53,9 @@ export const Empty = Template.bind({});
Empty.args = {
display: 'Editing',
alignment: 'center',
+ buttonColor: 'accent',
buttonText: '',
+ buttonTextColor: '#FFFFFF',
buttonPlaceholder: 'Add button text',
buttonUrl: ''
};
@@ -62,7 +64,9 @@ export const Populated = Template.bind({});
Populated.args = {
display: 'Editing',
alignment: 'center',
+ buttonColor: 'accent',
buttonText: 'Subscribe',
+ buttonTextColor: '#FFFFFF',
buttonPlaceholder: 'Add button text',
buttonUrl: 'https://ghost.org/'
-};
\ No newline at end of file
+};
diff --git a/packages/koenig-lexical/src/nodes/ButtonNode.jsx b/packages/koenig-lexical/src/nodes/ButtonNode.jsx
index 40f875b8dd..ba82e83714 100644
--- a/packages/koenig-lexical/src/nodes/ButtonNode.jsx
+++ b/packages/koenig-lexical/src/nodes/ButtonNode.jsx
@@ -34,7 +34,9 @@ export class ButtonNode extends BaseButtonNode {
>
diff --git a/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx b/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx
index 4c0dafbb87..2703ad6423 100644
--- a/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx
+++ b/packages/koenig-lexical/src/nodes/ButtonNodeComponent.jsx
@@ -8,7 +8,7 @@ import {SnippetActionToolbar} from '../components/ui/SnippetActionToolbar.jsx';
import {ToolbarMenu, ToolbarMenuItem, ToolbarMenuSeparator} from '../components/ui/ToolbarMenu.jsx';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
-export function ButtonNodeComponent({alignment, buttonText, buttonUrl, nodeKey}) {
+export function ButtonNodeComponent({alignment, buttonColor, buttonText, buttonTextColor, buttonUrl, nodeKey}) {
const [editor] = useLexicalComposerContext();
const {isEditing, isSelected, setEditing} = React.useContext(CardContext);
const {cardConfig} = React.useContext(KoenigComposerContext);
@@ -41,14 +41,25 @@ export function ButtonNodeComponent({alignment, buttonText, buttonUrl, nodeKey})
});
};
+ const handleButtonColorChange = (color, matchingTextColor) => {
+ editor.update(() => {
+ const node = $getNodeByKey(nodeKey);
+ node.buttonColor = color;
+ node.buttonTextColor = matchingTextColor;
+ });
+ };
+
return (
<>
>
);
-}
\ No newline at end of file
+}
diff --git a/packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx b/packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx
index b1472fcdde..a794e1f2b2 100644
--- a/packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx
+++ b/packages/koenig-lexical/src/plugins/CardMenuPlugin.jsx
@@ -1,12 +1,28 @@
import PlusCardMenuPlugin from '../plugins/PlusCardMenuPlugin';
import React from 'react';
import SlashCardMenuPlugin from '../plugins/SlashCardMenuPlugin';
+import {isMobileViewport} from '../utils/isMobileViewport';
export const CardMenuPlugin = () => {
+ const [hidePlusMenu, setHidePlusMenu] = React.useState(false);
+
+ React.useEffect(() => {
+ const updateViewportState = () => {
+ setHidePlusMenu(isMobileViewport());
+ };
+
+ updateViewportState();
+ window.addEventListener('resize', updateViewportState);
+
+ return () => {
+ window.removeEventListener('resize', updateViewportState);
+ };
+ }, []);
+
return (
<>
{/* Koenig Plugins */}
-
+ {!hidePlusMenu && }
>
);
diff --git a/packages/koenig-lexical/src/utils/isMobileViewport.js b/packages/koenig-lexical/src/utils/isMobileViewport.js
new file mode 100644
index 0000000000..98d2a4ea90
--- /dev/null
+++ b/packages/koenig-lexical/src/utils/isMobileViewport.js
@@ -0,0 +1,13 @@
+/**
+ * Determines if the current viewport is a mobile device in portrait orientation.
+ * Mobile is defined as width < 768px and height > width (portrait mode).
+ *
+ * @returns {boolean} True if the viewport is mobile portrait, false otherwise
+ */
+export function isMobileViewport() {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return window.innerWidth < 768 && window.innerHeight > window.innerWidth;
+}
diff --git a/packages/koenig-lexical/test/e2e/cards/button-card.test.js b/packages/koenig-lexical/test/e2e/cards/button-card.test.js
index 7c0414c099..2f1267e8db 100644
--- a/packages/koenig-lexical/test/e2e/cards/button-card.test.js
+++ b/packages/koenig-lexical/test/e2e/cards/button-card.test.js
@@ -1,5 +1,6 @@
import {assertHTML, createSnippet, focusEditor, html, initialize, insertCard} from '../../utils/e2e';
import {expect, test} from '@playwright/test';
+import {selectCustomColor} from '../../utils/color-select-helper';
test.describe('Button Card', async () => {
let page;
@@ -126,6 +127,27 @@ test.describe('Button Card', async () => {
await expect(buttonLink).toHaveAttribute('href','https://someblog.com/somepost');
});
+ test('button color picker works', async function () {
+ await focusEditor(page);
+ await insertCard(page, {cardName: 'button'});
+
+ await page.click('[data-testid="button-color"] [data-testid="color-selector-button"]');
+
+ await selectCustomColor(page, '#ff0000', 'color-picker-toggle');
+ await page.click('[data-testid="settings-panel"]');
+
+ await expect(page.locator('[data-testid="button-card-btn"]')).toHaveCSS('background-color', 'rgb(255, 0, 0)');
+ await expect(page.locator('[data-testid="button-card-btn"]')).toHaveCSS('color', 'rgb(255, 255, 255)');
+
+ await page.click('[data-testid="button-color"] [data-testid="color-selector-button"]');
+
+ await selectCustomColor(page, '#f7f7f7', null);
+ await page.click('[data-testid="settings-panel"]');
+
+ await expect(page.locator('[data-testid="button-card-btn"]')).toHaveCSS('background-color', 'rgb(247, 247, 247)');
+ await expect(page.locator('[data-testid="button-card-btn"]')).toHaveCSS('color', 'rgb(0, 0, 0)');
+ });
+
// NOTE: an improvement would be to pass in suggested url options, but the construction now doesn't make that straightforward
test('suggested urls display', async function () {
await focusEditor(page);
|