0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -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:
Jono M 2023-07-06 15:05:33 +12:00 committed by GitHub
parent b9158215ee
commit e1fbfd3e12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 277 additions and 5 deletions

View 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;

View file

@ -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'
}
};

View file

@ -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;

View file

@ -1,6 +1,7 @@
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
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 {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
import {Setting, SettingValue, Tier} from '../../../../types/api';
@ -15,9 +16,17 @@ const SignupOptions: React.FC<{
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
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 signupTermsLength = useMemo(() => {
const div = document.createElement('div');
div.innerHTML = portalSignupTermsHtml?.toString() || '';
return div.innerText.length;
}, [portalSignupTermsHtml]);
const togglePlan = (plan: string) => {
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
checked={Boolean(portalSignupCheckboxRequired)}

View file

@ -81,7 +81,8 @@ module.exports = {
inter: 'Inter',
sans: 'Inter, -apple-system, BlinkMacSystemFont, avenir next, avenir, helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif',
serif: 'Georgia, serif',
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace'
mono: 'Consolas, Liberation Mono, Menlo, Courier, monospace',
inherit: 'inherit'
},
boxShadow: {
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'],
'7xl': ['7.2rem', '1'],
'8xl': ['9.6rem', '1'],
'9xl': ['12.8rem', '1']
'9xl': ['12.8rem', '1'],
inherit: 'inherit'
},
lineHeight: {
base: '1.5em',