mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-04 02:01:58 -05:00
Added HTML editor using koenig-lexical (#17217)
refs https://github.com/TryGhost/Product/issues/3545 Used in portal settings for the signup message.
This commit is contained in:
parent
b9158215ee
commit
e1fbfd3e12
5 changed files with 277 additions and 5 deletions
apps/admin-x-settings
160
apps/admin-x-settings/src/admin-x-ds/global/form/HtmlEditor.tsx
Normal file
160
apps/admin-x-settings/src/admin-x-ds/global/form/HtmlEditor.tsx
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
|
||||||
|
|
||||||
|
export interface HtmlEditorProps {
|
||||||
|
value?: string
|
||||||
|
onChange?: (html: string) => void
|
||||||
|
onBlur?: () => void
|
||||||
|
placeholder?: string
|
||||||
|
nodes?: 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
'@tryghost/koenig-lexical': any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchKoenig = function ({editorUrl, editorVersion}: { editorUrl: string; editorVersion: string; }) {
|
||||||
|
let status = 'pending';
|
||||||
|
let response: any;
|
||||||
|
|
||||||
|
const fetchPackage = async () => {
|
||||||
|
if (window['@tryghost/koenig-lexical']) {
|
||||||
|
return window['@tryghost/koenig-lexical'];
|
||||||
|
}
|
||||||
|
|
||||||
|
await import(editorUrl.replace('{version}', editorVersion));
|
||||||
|
|
||||||
|
return window['@tryghost/koenig-lexical'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const suspender = fetchPackage().then(
|
||||||
|
(res) => {
|
||||||
|
status = 'success';
|
||||||
|
response = res;
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
status = 'error';
|
||||||
|
response = err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const read = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
throw suspender;
|
||||||
|
case 'error':
|
||||||
|
throw response;
|
||||||
|
default:
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {read};
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorResource = ReturnType<typeof fetchKoenig>;
|
||||||
|
|
||||||
|
class ErrorHandler extends React.Component<{ children: ReactNode }> {
|
||||||
|
state = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
return {hasError: true};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: any, errorInfo: any) {
|
||||||
|
console.error(error, errorInfo); // eslint-disable-line
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<p className="koenig-react-editor-error">Loading has failed. Try refreshing the browser!</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
|
||||||
|
editor,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
placeholder,
|
||||||
|
nodes
|
||||||
|
}) => {
|
||||||
|
const onError = useCallback((error: any) => {
|
||||||
|
// ensure we're still showing errors in development
|
||||||
|
console.error(error); // eslint-disable-line
|
||||||
|
|
||||||
|
// TODO: Sentry integration?
|
||||||
|
// if (this.config.sentry_dsn) {
|
||||||
|
// Sentry.captureException(error, {
|
||||||
|
// tags: {lexical: true},
|
||||||
|
// contexts: {
|
||||||
|
// koenig: {
|
||||||
|
// version: window['@tryghost/koenig-lexical']?.version
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// don't rethrow, Lexical will attempt to gracefully recover
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
|
||||||
|
get: (_target, prop) => {
|
||||||
|
return editor.read()[prop];
|
||||||
|
}
|
||||||
|
}), [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<koenig.KoenigComposer
|
||||||
|
nodes={koenig[nodes || 'DEFAULT_NODES']}
|
||||||
|
onError={onError}
|
||||||
|
>
|
||||||
|
<koenig.KoenigComposableEditor
|
||||||
|
className='koenig-lexical koenig-lexical-editor-input'
|
||||||
|
darkMode={false}
|
||||||
|
isSnippetsEnabled={false}
|
||||||
|
// TODO: set based on nodes or remove if there's another way to get the minimal editor
|
||||||
|
markdownTransformers={[]}
|
||||||
|
placeholderClassName='koenig-lexical-editor-input-placeholder'
|
||||||
|
placeholderText={placeholder}
|
||||||
|
singleParagraph={true}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<koenig.HtmlOutputPlugin html={value} setHtml={onChange} />
|
||||||
|
</koenig.KoenigComposableEditor>
|
||||||
|
</koenig.KoenigComposer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HtmlEditor: React.FC<HtmlEditorProps & {
|
||||||
|
config: { editor: { url: string; version: string; } };
|
||||||
|
className?: string;
|
||||||
|
}> = ({
|
||||||
|
config,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const editorResource = useMemo(() => fetchKoenig({
|
||||||
|
editorUrl: config.editor.url,
|
||||||
|
editorVersion: config.editor.version
|
||||||
|
}), [config.editor.url, config.editor.version]);
|
||||||
|
|
||||||
|
return <div className={className || 'h-[200px] w-full'}>
|
||||||
|
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
|
||||||
|
<ErrorHandler>
|
||||||
|
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||||
|
<KoenigWrapper {...props} editor={editorResource} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorHandler>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HtmlEditor;
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type {Meta, StoryObj} from '@storybook/react';
|
||||||
|
|
||||||
|
import HtmlField from './HtmlField';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Global / Form / Htmlfield',
|
||||||
|
component: HtmlField,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
config: {
|
||||||
|
editor: {
|
||||||
|
url: 'https://cdn.jsdelivr.net/ghost/koenig-lexical@~{version}/dist/koenig-lexical.umd.js',
|
||||||
|
version: '0.3'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof HtmlField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof HtmlField>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
placeholder: 'Enter something'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithHeading: Story = {
|
||||||
|
args: {
|
||||||
|
title: 'Title',
|
||||||
|
placeholder: 'Enter something'
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Heading from '../Heading';
|
||||||
|
import Hint from '../Hint';
|
||||||
|
import HtmlEditor, {HtmlEditorProps} from './HtmlEditor';
|
||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export type HtmlFieldProps = HtmlEditorProps & {
|
||||||
|
/**
|
||||||
|
* Should be passed the Ghost instance config to get the editor JS URL
|
||||||
|
*/
|
||||||
|
config: { editor: { url: string; version: string; } };
|
||||||
|
title?: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
clearBg?: boolean;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
hintClassName?: string;
|
||||||
|
unstyled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a mini Koenig editor using KoenigComposableEditor.
|
||||||
|
* Intended for use in settings forms where we don't need the full editor.
|
||||||
|
*/
|
||||||
|
const HtmlField: React.FC<HtmlFieldProps> = ({
|
||||||
|
title,
|
||||||
|
hideTitle,
|
||||||
|
error,
|
||||||
|
placeholder,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
clearBg = true,
|
||||||
|
className = '',
|
||||||
|
containerClassName = '',
|
||||||
|
hintClassName = '',
|
||||||
|
unstyled = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const textFieldClasses = unstyled ? '' : clsx(
|
||||||
|
'h-10 border-b py-2',
|
||||||
|
clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]',
|
||||||
|
error ? `border-red` : `border-grey-500 hover:border-grey-700 focus:border-black`,
|
||||||
|
(title && !hideTitle && !clearBg) && `mt-2`,
|
||||||
|
className
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${containerClassName}`}>
|
||||||
|
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} useLabelTag={true}>{title}</Heading>}
|
||||||
|
<div className={textFieldClasses}>
|
||||||
|
<HtmlEditor {...props} value={value} />
|
||||||
|
</div>
|
||||||
|
{hint && <Hint className={hintClassName} color={error ? 'red' : ''}>{hint}</Hint>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HtmlField;
|
|
@ -1,6 +1,7 @@
|
||||||
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
|
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
|
||||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import React, {useContext} from 'react';
|
import HtmlField from '../../../../admin-x-ds/global/form/HtmlField';
|
||||||
|
import React, {useContext, useMemo} from 'react';
|
||||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||||
import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
|
import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
|
||||||
import {Setting, SettingValue, Tier} from '../../../../types/api';
|
import {Setting, SettingValue, Tier} from '../../../../types/api';
|
||||||
|
@ -15,9 +16,17 @@ const SignupOptions: React.FC<{
|
||||||
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
|
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
|
||||||
const {config} = useContext(SettingsContext);
|
const {config} = useContext(SettingsContext);
|
||||||
|
|
||||||
const [membersSignupAccess, portalName, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required', 'portal_plans']);
|
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
|
||||||
|
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
|
||||||
|
);
|
||||||
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
||||||
|
|
||||||
|
const signupTermsLength = useMemo(() => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = portalSignupTermsHtml?.toString() || '';
|
||||||
|
return div.innerText.length;
|
||||||
|
}, [portalSignupTermsHtml]);
|
||||||
|
|
||||||
const togglePlan = (plan: string) => {
|
const togglePlan = (plan: string) => {
|
||||||
const index = portalPlans.indexOf(plan);
|
const index = portalPlans.indexOf(plan);
|
||||||
|
|
||||||
|
@ -98,7 +107,15 @@ const SignupOptions: React.FC<{
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='red text-sm'>TODO: Display notice at signup (Koenig)</div>
|
{/* TODO: validate length according to hint */}
|
||||||
|
<HtmlField
|
||||||
|
config={config as { editor: any }}
|
||||||
|
hint={<>Recommended: <strong>115</strong> characters. You've used <strong className="text-green">{signupTermsLength}</strong></>}
|
||||||
|
nodes='MINIMAL_NODES'
|
||||||
|
title='Display notice at signup'
|
||||||
|
value={portalSignupTermsHtml?.toString()}
|
||||||
|
onChange={html => updateSetting('portal_signup_terms_html', html)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Toggle
|
<Toggle
|
||||||
checked={Boolean(portalSignupCheckboxRequired)}
|
checked={Boolean(portalSignupCheckboxRequired)}
|
||||||
|
|
|
@ -81,7 +81,8 @@ module.exports = {
|
||||||
inter: 'Inter',
|
inter: 'Inter',
|
||||||
sans: 'Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif',
|
sans: 'Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif',
|
||||||
serif: 'Georgia, serif',
|
serif: 'Georgia, serif',
|
||||||
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace'
|
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace',
|
||||||
|
inherit: 'inherit'
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
DEFAULT: '0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08)',
|
DEFAULT: '0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08)',
|
||||||
|
@ -227,7 +228,8 @@ module.exports = {
|
||||||
'6xl': ['6rem', '1'],
|
'6xl': ['6rem', '1'],
|
||||||
'7xl': ['7.2rem', '1'],
|
'7xl': ['7.2rem', '1'],
|
||||||
'8xl': ['9.6rem', '1'],
|
'8xl': ['9.6rem', '1'],
|
||||||
'9xl': ['12.8rem', '1']
|
'9xl': ['12.8rem', '1'],
|
||||||
|
inherit: 'inherit'
|
||||||
},
|
},
|
||||||
lineHeight: {
|
lineHeight: {
|
||||||
base: '1.5em',
|
base: '1.5em',
|
||||||
|
|
Loading…
Add table
Reference in a new issue