diff --git a/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js b/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js index 923f12a417..c5593de434 100644 --- a/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js +++ b/packages/kg-default-nodes/lib/nodes/button/ButtonNode.js @@ -8,7 +8,9 @@ export class ButtonNode extends generateDecoratorNode({ properties: [ {name: 'buttonText', default: ''}, {name: 'alignment', default: 'center'}, - {name: 'buttonUrl', default: '', urlType: 'url'} + {name: 'buttonUrl', default: '', urlType: 'url'}, + {name: 'buttonColor', default: 'accent'}, + {name: 'buttonTextColor', default: '#ffffff'} ], defaultRenderFn: renderButtonNode }) { diff --git a/packages/kg-default-nodes/lib/nodes/button/button-parser.js b/packages/kg-default-nodes/lib/nodes/button/button-parser.js index 7e6e326f1b..80804530a4 100644 --- a/packages/kg-default-nodes/lib/nodes/button/button-parser.js +++ b/packages/kg-default-nodes/lib/nodes/button/button-parser.js @@ -1,3 +1,5 @@ +import {rgbToHex} from '../../utils/rgb-to-hex'; + export function parseButtonNode(ButtonNode) { return { div: (nodeElem) => { @@ -13,13 +15,18 @@ export function parseButtonNode(ButtonNode) { } const buttonNode = domNode?.querySelector('.kg-btn'); - const buttonUrl = buttonNode.getAttribute('href'); - const buttonText = buttonNode.textContent; + const buttonUrl = buttonNode?.getAttribute('href'); + const buttonText = buttonNode?.textContent; + const isAccentButton = buttonNode?.classList?.contains('kg-btn-accent') ?? false; + const buttonColor = isAccentButton ? 'accent' : (rgbToHex(buttonNode?.style?.backgroundColor) || 'accent'); + const buttonTextColor = rgbToHex(buttonNode?.style?.color) || '#ffffff'; const payload = { buttonText: buttonText, alignment: alignment, - buttonUrl: buttonUrl + buttonUrl: buttonUrl, + buttonColor: buttonColor, + buttonTextColor: buttonTextColor }; const node = new ButtonNode(payload); diff --git a/packages/kg-default-nodes/lib/nodes/button/button-renderer.js b/packages/kg-default-nodes/lib/nodes/button/button-renderer.js index d72ee8cc56..7275d79021 100644 --- a/packages/kg-default-nodes/lib/nodes/button/button-renderer.js +++ b/packages/kg-default-nodes/lib/nodes/button/button-renderer.js @@ -20,13 +20,18 @@ export function renderButtonNode(node, options = {}) { function frontendTemplate(node, document) { const cardClasses = getCardClasses(node); + const buttonClasses = getButtonClasses(node); + const buttonStyle = getButtonStyle(node); const cardDiv = document.createElement('div'); cardDiv.setAttribute('class', cardClasses); const button = document.createElement('a'); button.setAttribute('href', node.buttonUrl); - button.setAttribute('class', 'kg-btn kg-btn-accent'); + button.setAttribute('class', buttonClasses); + if (buttonStyle) { + button.setAttribute('style', buttonStyle); + } button.textContent = node.buttonText || 'Button Title'; cardDiv.appendChild(button); @@ -34,7 +39,10 @@ function frontendTemplate(node, document) { } function emailTemplate(node, options, document) { - const {buttonUrl, buttonText} = node; + const {buttonUrl, buttonText, buttonColor = 'accent', buttonTextColor = '#ffffff'} = node; + const buttonClasses = buttonColor === 'accent' ? 'btn btn-accent' : 'btn'; + const buttonStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : ''; + const textStyle = (buttonTextColor && (buttonColor !== 'accent' || buttonTextColor !== '#ffffff')) ? `style="color: ${buttonTextColor};"` : ''; let cardHtml; if (options.feature?.emailCustomization) { @@ -42,10 +50,10 @@ function emailTemplate(node, options, document) {
- +
-
- ${buttonText} + + ${buttonText}
@@ -59,9 +67,10 @@ function emailTemplate(node, options, document) { } else if (options.feature?.emailCustomizationAlpha) { const buttonHtml = renderEmailButton({ alignment: node.alignment, - color: 'accent', + color: buttonColor, url: buttonUrl, - text: buttonText + text: buttonText, + textColor: buttonTextColor }); cardHtml = html` @@ -80,12 +89,14 @@ function emailTemplate(node, options, document) { element.innerHTML = cardHtml; return {element, type: 'inner'}; } else { + const wrapperStyle = buttonColor !== 'accent' ? `style="background-color: ${buttonColor};"` : ''; + const wrapperClass = buttonColor === 'accent' ? 'btn btn-accent' : 'btn'; cardHtml = html` -
+
- ${buttonText} + ${buttonText}
@@ -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` -
- ${text} + + ${text}
`; -} \ 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` + + + + + + +
+ + + + + + +
+ click me +
+
+ `); + })); + 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);