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:
parent
b9158215ee
commit
e1fbfd3e12
5 changed files with 277 additions and 5 deletions
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 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)}
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue