Reimplement preview styles, add frame flag, clean up markdown editor options

This commit is contained in:
Daniel Lautzenheiser
2022-10-26 14:55:26 -04:00
parent e36698aa9a
commit 3486005244
28 changed files with 421 additions and 1213 deletions

View File

@ -174,6 +174,10 @@ export function applyDefaults(originalConfig: Config) {
for (const collection of config.collections) {
let collectionI18n = collection[I18N];
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview, frame: config.editor.frame };
}
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
@ -240,6 +244,10 @@ export function applyDefaults(originalConfig: Config) {
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
if (collection.editor && !file.editor) {
file.editor = { preview: collection.editor.preview, frame: collection.editor.frame };
}
}
}
@ -266,10 +274,6 @@ export function applyDefaults(originalConfig: Config) {
id: `${group.field}__${group.pattern}`,
};
});
if (config.editor && !collection.editor) {
collection.editor = { preview: config.editor.preview };
}
}
});
}

View File

@ -293,4 +293,4 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export type CollectionViewProps = ConnectedProps<typeof connector>;
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;
export default connector(translate()(CollectionView) as ComponentType<CollectionViewProps>);

View File

@ -146,12 +146,10 @@ const EditorControl = ({
submitted,
getAsset,
isDisabled,
isEditorComponent,
isFetching,
isFieldDuplicate,
isFieldHidden,
isHidden = false,
isNewEditorComponent,
loadEntry,
locale,
mediaPaths,
@ -223,11 +221,9 @@ const EditorControl = ({
submitted,
getAsset: handleGetAsset(collection, entry),
isDisabled: isDisabled ?? false,
isEditorComponent: isEditorComponent ?? false,
isFetching,
isFieldDuplicate,
isFieldHidden,
isNewEditorComponent: isNewEditorComponent ?? false,
label: getFieldLabel(field, t),
loadEntry,
locale,
@ -270,11 +266,9 @@ interface EditorControlOwnProps {
fieldsErrors: FieldsErrors;
submitted: boolean;
isDisabled?: boolean;
isEditorComponent?: boolean;
isFieldDuplicate?: (field: Field) => boolean;
isFieldHidden?: (field: Field) => boolean;
isHidden?: boolean;
isNewEditorComponent?: boolean;
locale?: string;
parentPath: string;
value: ValueOrNestedValue;

View File

@ -122,17 +122,6 @@ const EditorContent = ({
}
};
function isPreviewEnabled(collection: Collection, entry: Entry) {
if (collection.type === FILES) {
const file = getFileFromSlug(collection, entry.slug);
const previewEnabled = file?.editor?.preview ?? false;
if (previewEnabled) {
return previewEnabled;
}
}
return collection.editor?.preview ?? true;
}
interface EditorInterfaceProps {
draftKey: string;
entry: Entry;
@ -228,7 +217,23 @@ const EditorInterface = ({
setSelectedLocale(locale);
}, []);
const previewEnabled = isPreviewEnabled(collection, entry);
const [previewEnabled, previewInFrame] = useMemo(() => {
let preview = collection.editor?.preview ?? true;
let frame = collection.editor?.frame ?? true;
if (collection.type === FILES) {
const file = getFileFromSlug(collection, entry.slug);
if (file?.editor?.preview !== undefined) {
preview = file.editor.preview;
}
if (file?.editor?.frame !== undefined) {
frame = file.editor.frame;
}
}
return [preview, frame];
}, [collection, entry.slug]);
const collectionI18nEnabled = hasI18n(collection);
@ -271,7 +276,12 @@ const EditorInterface = ({
<StyledSplitPane>
<ScrollSyncPane>{editor}</ScrollSyncPane>
<PreviewPaneContainer>
<EditorPreviewPane collection={collection} entry={previewEntry} fields={fields} />
<EditorPreviewPane
collection={collection}
previewInFrame={previewInFrame}
entry={previewEntry}
fields={fields}
/>
</PreviewPaneContainer>
</StyledSplitPane>
</>

View File

@ -1,34 +1,15 @@
import { styled } from '@mui/material/styles';
import React, { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { ScrollSyncPane } from 'react-scroll-sync';
import type { TemplatePreviewComponent, TemplatePreviewProps } from '../../../interface';
import type { ReactNode } from 'react';
import type { TemplatePreviewComponent, TemplatePreviewProps } from '../../../interface';
interface PreviewContentProps {
previewComponent?: TemplatePreviewComponent;
previewProps: TemplatePreviewProps;
}
const StyledPreviewContent = styled('div')`
width: calc(100% - min(864px, 50%));
top: 64px;
right: 0;
position: absolute;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 16px;
`;
const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => {
const element = useMemo(() => document.getElementById('cms-root'), []);
return useMemo(() => {
if (!element) {
return null;
}
let children: ReactNode;
if (!previewComponent) {
children = null;
@ -38,14 +19,8 @@ const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps)
children = React.createElement(previewComponent, previewProps);
}
return ReactDOM.createPortal(
<ScrollSyncPane>
<StyledPreviewContent className="preview-content">{children}</StyledPreviewContent>
</ScrollSyncPane>,
element,
'preview-content',
);
}, [previewComponent, previewProps, element]);
return children;
}, [previewComponent, previewProps]);
};
export default PreviewContent;

View File

@ -1,11 +1,15 @@
import { styled } from '@mui/material/styles';
import React, { isValidElement, useCallback, useMemo } from 'react';
import ReactDOM from 'react-dom';
import Frame, { FrameContextConsumer } from 'react-frame-component';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { ScrollSyncPane } from 'react-scroll-sync';
import { getAsset as getAssetAction } from '../../../actions/media';
import { lengths } from '../../../components/UI/styles';
import { INFERABLE_FIELDS } from '../../../constants/fieldInference';
import { getPreviewTemplate, getRemarkPlugins, resolveWidget } from '../../../lib/registry';
import { getPreviewStyles, getPreviewTemplate, resolveWidget } from '../../../lib/registry';
import { selectInferedField, selectTemplateName } from '../../../lib/util/collection.util';
import { selectField } from '../../../lib/util/field.util';
import { selectIsLoadingAsset } from '../../../reducers/medias';
@ -14,21 +18,22 @@ import EditorPreview from './EditorPreview';
import EditorPreviewContent from './EditorPreviewContent';
import PreviewHOC from './PreviewHOC';
import type { ReactFragment, ReactNode } from 'react';
import type { ComponentType, ReactFragment, ReactNode } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { InferredField } from '../../../constants/fieldInference';
import type {
Field,
TemplatePreviewProps,
Collection,
Entry,
EntryData,
Field,
GetAssetFunction,
TemplatePreviewProps,
TranslatedProps,
ValueOrNestedValue,
} from '../../../interface';
import type { RootState } from '../../../store';
const PreviewPaneFrame = styled('div')`
const PreviewPaneFrame = styled(Frame)`
width: 100%;
height: 100%;
border: none;
@ -37,6 +42,25 @@ const PreviewPaneFrame = styled('div')`
overflow: auto;
`;
const PreviewPaneWrapper = styled('div')`
width: 100%;
height: 100%;
border: none;
background: #fff;
border-radius: ${lengths.borderRadius};
overflow: auto;
`;
const StyledPreviewContent = styled('div')`
width: calc(100% - min(864px, 50%));
top: 64px;
right: 0;
position: absolute;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 16px;
`;
/**
* Returns the widget component for a named field, and makes recursive calls
* to retrieve components for nested and deeply nested fields, which occur in
@ -162,7 +186,6 @@ function getWidget(
}
entry={entry}
resolveWidget={resolveWidget}
getRemarkPlugins={getRemarkPlugins}
/>
);
}
@ -234,8 +257,8 @@ function getNestedWidgets(
);
}
const PreviewPane = (props: EditorPreviewPaneProps) => {
const { entry, collection, config, fields, getAsset } = props;
const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
const { entry, collection, config, fields, previewInFrame, getAsset, t } = props;
const inferedFields = useMemo(() => {
const titleField = selectInferedField(collection, 'title');
@ -307,39 +330,120 @@ const PreviewPane = (props: EditorPreviewPaneProps) => {
[collection, entry, fields, handleGetAsset, inferedFields],
);
if (!entry || !entry.data) {
return null;
}
const previewComponent =
getPreviewTemplate(selectTemplateName(collection, entry.slug)) ?? EditorPreview;
const previewProps: TemplatePreviewProps = {
...props,
getAsset: handleGetAsset,
widgetFor,
widgetsFor,
};
if (!collection) {
<PreviewPaneFrame id="preview-pane" />;
}
return (
<ErrorBoundary config={config}>
<PreviewPaneFrame id="preview-pane">
<EditorPreviewContent
{...{ previewComponent, previewProps: { ...previewProps, document, window } }}
/>
</PreviewPaneFrame>
</ErrorBoundary>
const previewStyles = useMemo(
() =>
getPreviewStyles().map((style, i) => {
if (style.raw) {
return <style key={i}>{style.value}</style>;
}
return <link key={i} href={style.value} type="text/css" rel="stylesheet" />;
}),
[],
);
const previewComponent = useMemo(
() => getPreviewTemplate(selectTemplateName(collection, entry.slug)) ?? EditorPreview,
[collection, entry.slug],
);
const initialFrameContent = useMemo(
() => `
<!DOCTYPE html>
<html>
<head><base target="_blank"/></head>
<body><div></div></body>
</html>
`,
[],
);
const element = useMemo(() => document.getElementById('cms-root'), []);
const previewProps: Omit<TemplatePreviewProps, 'document' | 'window'> = useMemo(
() => ({
...props,
getAsset: handleGetAsset,
widgetFor,
widgetsFor,
}),
[handleGetAsset, props, widgetFor, widgetsFor],
);
return useMemo(() => {
if (!element) {
return null;
}
return ReactDOM.createPortal(
<ScrollSyncPane>
<StyledPreviewContent className="preview-content">
{!entry || !entry.data ? null : (
<ErrorBoundary config={config}>
{previewInFrame ? (
<PreviewPaneFrame
key="preview-frame"
id="preview-pane"
head={previewStyles}
initialContent={initialFrameContent}
>
{!collection ? (
t('collection.notFound')
) : (
<FrameContextConsumer>
{({ document, window }) => {
return (
<EditorPreviewContent
key="preview-frame-content"
previewComponent={previewComponent}
previewProps={{ ...previewProps, document, window }}
/>
);
}}
</FrameContextConsumer>
)}
</PreviewPaneFrame>
) : (
<PreviewPaneWrapper key="preview-wrapper" id="preview-pane">
{!collection ? (
t('collection.notFound')
) : (
<>
{previewStyles}
<EditorPreviewContent
key="preview-wrapper-content"
previewComponent={previewComponent}
previewProps={{ ...previewProps, document, window }}
/>
</>
)}
</PreviewPaneWrapper>
)}
</ErrorBoundary>
)}
</StyledPreviewContent>
</ScrollSyncPane>,
element,
'preview-content',
);
}, [
collection,
config,
element,
entry,
initialFrameContent,
previewComponent,
previewInFrame,
previewProps,
previewStyles,
t,
]);
};
export interface EditorPreviewPaneOwnProps {
collection: Collection;
fields: Field[];
entry: Entry;
previewInFrame: boolean;
}
function mapStateToProps(state: RootState, ownProps: EditorPreviewPaneOwnProps) {
@ -354,4 +458,4 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EditorPreviewPaneProps = ConnectedProps<typeof connector>;
export default connector(PreviewPane);
export default connector(translate()(PreviewPane) as ComponentType<EditorPreviewPaneProps>);

View File

@ -1,50 +0,0 @@
import React from 'react';
import type { EditorComponentManualOptions } from '../../interface';
interface ImageData {
alt: string;
image: string;
title: string;
}
const image: EditorComponentManualOptions<ImageData> = {
label: 'Image',
id: 'image',
fromBlock: match =>
match && {
image: match[2],
alt: match[1],
title: match[4],
},
toBlock: ({ alt, image, title }) =>
`![${alt || ''}](${image || ''}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`,
// eslint-disable-next-line react/display-name
toPreview: ({ alt, image, title }, getAsset, fields) => {
const imageField = fields?.find(f => f.widget === 'image');
const src = getAsset(image, imageField).toString();
return <img src={src || ''} alt={alt || ''} title={title || ''} />;
},
pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/,
fields: [
{
label: 'Image',
name: 'image',
widget: 'image',
media_library: {
allow_multiple: false,
},
},
{
label: 'Alt Text',
name: 'alt',
},
{
label: 'Title',
name: 'title',
},
],
};
export const StaticCmsEditorComponentImage = image;
export default image;

View File

@ -1 +0,0 @@
export { default as imageEditorComponent } from './image';

View File

@ -7,10 +7,8 @@ import {
ProxyBackend,
TestBackend,
} from './backends';
import { imageEditorComponent } from './editor-components';
import {
registerBackend,
registerEditorComponent,
registerLocale,
registerWidget,
} from './lib/registry';
@ -59,12 +57,5 @@ export function addExtensions() {
CodeWidget(),
ColorStringWidget(),
]);
registerEditorComponent(imageEditorComponent);
registerEditorComponent({
id: 'code-block',
label: 'Code Block',
widget: 'code',
type: 'code-block',
});
registerLocale('en', locales.en);
}

View File

@ -7,7 +7,6 @@ import Registry from './lib/registry';
export * from './backends';
export * from './widgets';
export * from './media-libraries';
export * from './editor-components';
export * from './locales';
export * from './lib';

View File

@ -1,7 +1,8 @@
import type { EditorPlugin, EditorType, WidgetRule } from '@toast-ui/editor/types/editor';
import type { ToolbarItemOptions } from '@toast-ui/editor/types/ui';
import type { PropertiesSchema } from 'ajv/dist/types/json-schema';
import type { ComponentType, ReactNode } from 'react';
import type { t, TranslateProps as ReactPolyglotTranslateProps } from 'react-polyglot';
import type { Pluggable } from 'unified';
import type { MediaFile as BackendMediaFile } from './backend';
import type { EditorControlProps } from './components/Editor/EditorControlPane/EditorControl';
import type { formatExtensions } from './formats/formats';
@ -126,6 +127,11 @@ export interface FilterRule {
field: string;
}
export interface EditorConfig {
preview?: boolean;
frame?: boolean;
}
export interface CollectionFile {
name: string;
label: string;
@ -136,9 +142,7 @@ export interface CollectionFile {
media_folder?: string;
public_folder?: string;
i18n?: boolean | I18nInfo;
editor?: {
preview?: boolean;
};
editor?: EditorConfig;
}
interface Nested {
@ -189,9 +193,7 @@ export interface Collection {
nested?: Nested;
i18n?: boolean | I18nInfo;
hide?: boolean;
editor?: {
preview?: boolean;
};
editor?: EditorConfig;
}
export type Collections = Record<string, Collection>;
@ -236,11 +238,9 @@ export interface WidgetControlProps<T, F extends Field = Field> {
forList: boolean;
getAsset: GetAssetFunction;
isDisabled: boolean;
isEditorComponent: boolean;
isFetching: boolean;
isFieldDuplicate: EditorControlProps['isFieldDuplicate'];
isFieldHidden: EditorControlProps['isFieldHidden'];
isNewEditorComponent: boolean;
label: string;
loadEntry: EditorControlProps['loadEntry'];
locale: string | undefined;
@ -262,7 +262,6 @@ export interface WidgetPreviewProps<T = unknown, F extends Field = Field> {
entry: Entry;
field: F;
getAsset: GetAssetFunction;
getRemarkPlugins: () => Pluggable[];
resolveWidget: <W = unknown, WF extends Field = Field>(name: string) => Widget<W, WF>;
value: T | undefined | null;
}
@ -276,6 +275,8 @@ export interface TemplatePreviewProps {
collection: Collection;
fields: Field[];
entry: Entry;
document: Document | undefined | null;
window: Window | undefined | null;
getAsset: GetAssetFunction;
widgetFor: (name: string) => ReactNode;
widgetsFor: (name: string) =>
@ -780,9 +781,7 @@ export interface Config {
slug?: Slug;
i18n?: I18nInfo;
local_backend?: boolean | LocalBackend;
editor?: {
preview?: boolean;
};
editor?: EditorConfig;
search?: boolean;
}
@ -798,34 +797,6 @@ export interface BackendInitializer {
init: (config: Config, options: BackendInitializerOptions) => BackendClass;
}
export interface EditorComponentWidgetOptions {
id: string;
label: string;
widget?: string;
type: string;
}
export interface EditorComponentManualOptions<T = EntryData> {
id: string;
label: string;
fields: Field[];
pattern: RegExp;
allow_add?: boolean;
fromBlock: (match: RegExpMatchArray) => T;
toBlock: (data: T) => string;
toPreview: (data: T, getAsset: GetAssetFunction, fields: Field[]) => ReactNode;
}
export function isEditorComponentWidgetOptions(
options: EditorComponentOptions,
): options is EditorComponentWidgetOptions {
return 'widget' in options;
}
export type EditorComponentOptions<T = EntryData> =
| EditorComponentManualOptions<T>
| EditorComponentWidgetOptions;
export interface EventData {
entry: Entry;
author: { login: string | undefined; name: string };
@ -918,7 +889,39 @@ export interface ProcessedCodeLanguage {
codemirror_mime_type: string;
}
export type FileMetadata = {
export interface FileMetadata {
author: string;
updatedOn: string;
};
}
export interface PreviewStyleOptions {
raw?: boolean;
}
export interface PreviewStyle {
value: string;
raw: boolean;
}
export interface WidgetRulesFactoryProps {
getAsset: GetAssetFunction;
field: MarkdownField;
}
export type WidgetRulesFactory = (props: WidgetRulesFactoryProps) => WidgetRule[];
export interface ToolbarItemsFactoryProps {
imageToolbarButton: ToolbarItemOptions;
}
export type ToolbarItemsFactory = (
props: ToolbarItemsFactoryProps,
) => (string | ToolbarItemOptions)[][];
export interface MarkdownEditorOptions {
widgetRules?: WidgetRulesFactory;
initialEditType?: EditorType;
height?: string;
toolbarItems?: ToolbarItemsFactory;
plugins?: EditorPlugin[];
}

View File

@ -1,28 +1,27 @@
import { oneLine } from 'common-tags';
import EditorComponent from '../valueObjects/EditorComponent';
import type { Pluggable } from 'unified';
import type {
AdditionalLink,
BackendClass,
BackendInitializer,
BackendInitializerOptions,
Config,
EventListener,
Field,
CustomIcon,
LocalePhrasesRoot,
MediaLibraryExternalLibrary,
MediaLibraryOptions,
TemplatePreviewComponent,
WidgetParam,
WidgetValueSerializer,
EditorComponentOptions,
Entry,
EventData,
EventListener,
Field,
LocalePhrasesRoot,
MarkdownEditorOptions,
MediaLibraryExternalLibrary,
MediaLibraryOptions,
PreviewStyle,
PreviewStyleOptions,
TemplatePreviewComponent,
Widget,
WidgetOptions,
WidgetParam,
WidgetValueSerializer,
} from '../interface';
export const allowedEvents = ['prePublish', 'postPublish', 'preSave', 'postSave'] as const;
@ -39,12 +38,14 @@ interface Registry {
widgets: Record<string, Widget>;
icons: Record<string, CustomIcon>;
additionalLinks: Record<string, AdditionalLink>;
editorComponents: Record<string, EditorComponentOptions>;
remarkPlugins: Pluggable[];
widgetValueSerializers: Record<string, WidgetValueSerializer>;
mediaLibraries: (MediaLibraryExternalLibrary & { options: MediaLibraryOptions })[];
locales: Record<string, LocalePhrasesRoot>;
eventHandlers: typeof eventHandlers;
previewStyles: PreviewStyle[];
/** Markdown editor */
markdownEditorConfig: MarkdownEditorOptions;
}
/**
@ -56,12 +57,12 @@ const registry: Registry = {
widgets: {},
icons: {},
additionalLinks: {},
editorComponents: {},
remarkPlugins: [],
widgetValueSerializers: {},
mediaLibraries: [],
locales: {},
eventHandlers,
previewStyles: [],
markdownEditorConfig: {},
};
export default {
@ -71,10 +72,6 @@ export default {
getWidget,
getWidgets,
resolveWidget,
registerEditorComponent,
getEditorComponents,
registerRemarkPlugin,
getRemarkPlugins,
registerWidgetValueSerializer,
getWidgetValueSerializer,
registerBackend,
@ -91,8 +88,24 @@ export default {
getIcon,
registerAdditionalLink,
getAdditionalLinks,
registerPreviewStyle,
getPreviewStyles,
};
/**
* Preview Styles
*
* Valid options:
* - raw {boolean} if `true`, `style` value is expected to be a CSS string
*/
export function registerPreviewStyle(style: string, { raw = false }: PreviewStyleOptions = {}) {
registry.previewStyles.push({ value: style, raw });
}
export function getPreviewStyles() {
return registry.previewStyles;
}
/**
* Preview Templates
*/
@ -196,44 +209,6 @@ export function resolveWidget<T = unknown, F extends Field = Field>(name?: strin
return getWidget(name || 'string') || getWidget('unknown');
}
/**
* Markdown Editor Custom Components
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerEditorComponent(component: EditorComponentOptions<any>) {
const plugin = EditorComponent(component);
if ('type' in plugin && plugin.type === 'code-block') {
const codeBlock = Object.values(registry.editorComponents).find(
c => 'type' in c && c.type === 'code-block',
);
if (codeBlock) {
console.warn(oneLine`
Only one editor component of type "code-block" may be registered. Previously registered code
block component(s) will be overwritten.
`);
}
}
registry.editorComponents[plugin.id] = plugin;
}
export function getEditorComponents(): Record<string, EditorComponentOptions> {
return registry.editorComponents;
}
/**
* Remark plugins
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function registerRemarkPlugin(plugin: Pluggable) {
registry.remarkPlugins.push(plugin);
}
export function getRemarkPlugins(): Pluggable[] {
return registry.remarkPlugins;
}
/**
* Widget Serializers
*/
@ -383,3 +358,14 @@ export function getAdditionalLinks(): Record<string, AdditionalLink> {
export function getAdditionalLink(id: string): AdditionalLink | undefined {
return registry.additionalLinks[id];
}
/**
* Markdown editor options
*/
export function setMarkdownEditorOptions(options: MarkdownEditorOptions) {
registry.markdownEditorConfig = options;
}
export function getMarkdownEditorOptions(): MarkdownEditorOptions {
return registry.markdownEditorConfig;
}

View File

@ -72,6 +72,7 @@ const en: LocalePhrasesRoot = {
label: 'Updated On',
},
},
notFound: 'Collection not found',
},
editor: {
editorControl: {

View File

@ -1,5 +0,0 @@
declare module 'mdast-util-definitions';
declare module 'unist-builder';
declare module 'rehype-stringify';
declare module 'remark-parse';
declare module 'remark-rehype';

View File

@ -1,55 +0,0 @@
import isFunction from 'lodash/isFunction';
import { isEditorComponentWidgetOptions } from '../interface';
import type { EditorComponentOptions } from '../interface';
const catchesNothing = /.^/;
function bind(fn: unknown) {
return isFunction(fn) && fn.bind(null);
}
export default function createEditorComponent(
options: EditorComponentOptions,
): EditorComponentOptions {
if (isEditorComponentWidgetOptions(options)) {
const {
id = null,
label = 'unnamed component',
type = 'shortcode',
widget = 'object',
...remainingConfig
} = options;
return {
id: id || label.replace(/[^A-Z0-9]+/gi, '_'),
label,
type,
widget,
...remainingConfig,
};
}
const {
id = null,
label = 'unnamed component',
pattern = catchesNothing,
fields = [],
fromBlock,
toBlock,
toPreview,
...remainingConfig
} = options;
return {
id: id || label.replace(/[^A-Z0-9]+/gi, '_'),
label,
pattern,
fromBlock: bind(fromBlock) || (() => ({})),
toBlock: bind(toBlock) || (() => 'Plugin'),
toPreview: bind(toPreview) || bind(toBlock) || (() => 'Plugin'),
fields,
...remainingConfig,
};
}

View File

@ -88,8 +88,6 @@ const settingsPersistKeys = {
};
const CodeControl = ({
isEditorComponent,
isNewEditorComponent,
field,
onChange,
hasErrors,
@ -112,31 +110,20 @@ const CodeControl = ({
);
// If the value is a map, keys can be customized via config.
const getKeys = useCallback(
(field: CodeField) => {
const defaults = {
code: 'code',
lang: 'lang',
};
const getKeys = useCallback((field: CodeField) => {
const defaults = {
code: 'code',
lang: 'lang',
};
// Force default keys if widget is an editor component code block.
if (isEditorComponent) {
return defaults;
}
const keys = field.keys ?? {};
return { ...defaults, ...keys };
},
[isEditorComponent],
);
const keys = field.keys ?? {};
return { ...defaults, ...keys };
}, []);
const keys = useMemo(() => getKeys(field), [field, getKeys]);
// Determine if the persisted value is a map rather than a plain string. A map value allows both the code string and the language to be persisted.
const valueIsMap = useMemo(
() => Boolean(!field.output_code_only || isEditorComponent),
[field.output_code_only, isEditorComponent],
);
const valueIsMap = useMemo(() => Boolean(!field.output_code_only), [field.output_code_only]);
// This widget is not fully controlled, it only takes a value through props upon initialization.
const getInitialLang = useCallback(() => {
@ -362,9 +349,6 @@ const CodeControl = ({
detach={true}
editorDidMount={cm => {
setCodemirrorEditor(cm);
if (isNewEditorComponent) {
handleFocus();
}
}}
value={lastKnownValue}
onChange={(_editor, _data, newValue) => handleChange(newValue)}

View File

@ -7,18 +7,18 @@ import uuid from 'uuid';
import FieldLabel from '../../components/UI/FieldLabel';
import Outline from '../../components/UI/Outline';
import { IMAGE_EXTENSION_REGEX } from '../../constants/files';
import useImagePlugin from '../../editor-components/editorPlugin';
import { doesUrlFileExist } from '../../lib/util/fetch.util';
import { isNotNullish } from '../../lib/util/null.util';
import { isNotEmpty } from '../../lib/util/string.util';
import useEditorOptions from './hooks/useEditorOptions';
import useToolbarItems from './hooks/useToolbarItems';
import useWidgetRules from './hooks/useWidgetRules';
import type { RefObject } from 'react';
import type { MarkdownField, MediaLibrary, WidgetControlProps } from '../../interface';
import '@toast-ui/editor/dist/toastui-editor.css';
const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/;
const StyledEditorWrapper = styled('div')`
position: relative;
width: 100%;
@ -91,10 +91,6 @@ const MarkdownControl = ({
[controlID, field, mediaLibraryFieldOptions, openMediaLibrary],
);
const imageToolbarButton = useImagePlugin({
openMediaLibrary: handleOpenMedialLibrary,
});
const getMedia = useCallback(
async (path: string) => {
const { type, exists } = await doesUrlFileExist(path);
@ -155,6 +151,10 @@ const MarkdownControl = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field, mediaPath]);
const { initialEditType, height, plugins, ...markdownEditorOptions } = useEditorOptions();
const widgetRules = useWidgetRules(markdownEditorOptions.widgetRules, { getAsset, field });
const toolbarItems = useToolbarItems(markdownEditorOptions.toolbarItems, handleOpenMedialLibrary);
return (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
@ -169,50 +169,17 @@ const MarkdownControl = ({
key="markdown-control-editor"
initialValue={internalValue}
previewStyle="vertical"
height="600px"
initialEditType="markdown"
height={height}
initialEditType={initialEditType}
useCommandShortcut={true}
onChange={handleOnChange}
toolbarItems={[
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', imageToolbarButton, 'link'],
['code', 'codeblock'],
]}
toolbarItems={toolbarItems}
ref={editorRef}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
autofocus={false}
widgetRules={[
{
rule: imageFilePattern,
toDOM(text) {
const rule = imageFilePattern;
const matched = text.match(rule);
if (matched) {
if (matched?.length === 4) {
// Image
const img = document.createElement('img');
img.setAttribute('src', getAsset(matched[3], field).url);
img.setAttribute('style', 'width: 100%;');
img.innerHTML = 'test';
return img;
} else {
// File
const a = document.createElement('a');
a.setAttribute('target', '_blank');
a.setAttribute('href', matched[2]);
a.innerHTML = matched[1];
return a;
}
}
return document.createElement('div');
},
},
]}
widgetRules={widgetRules}
plugins={plugins}
/>
<Outline key="markdown-control-outline" hasLabel hasError={hasErrors} />
</StyledEditorWrapper>

View File

@ -1,28 +1,28 @@
import DOMPurify from 'dompurify';
import React from 'react';
import { Viewer } from '@toast-ui/react-editor';
import React, { useEffect, useRef } from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import { markdownToHtml } from './serializers';
import useEditorOptions from './hooks/useEditorOptions';
import type { MarkdownField, WidgetPreviewProps } from '../../interface';
const MarkdownPreview = ({
value,
getAsset,
field,
getRemarkPlugins,
}: WidgetPreviewProps<string, MarkdownField>) => {
const MarkdownPreview = ({ value }: WidgetPreviewProps<string, MarkdownField>) => {
const { plugins } = useEditorOptions();
const viewer = useRef<Viewer | null>(null);
useEffect(() => {
viewer.current?.getInstance().setMarkdown(value ?? '');
}, [value]);
if (!value) {
return null;
}
const html = markdownToHtml(value, {
getAsset,
remarkPlugins: getRemarkPlugins(),
});
const toRender = field.sanitize_preview ?? false ? DOMPurify.sanitize(html) : html;
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;
return (
<WidgetPreviewContainer>
<Viewer ref={viewer} initialValue={value} plugins={plugins} />
</WidgetPreviewContainer>
);
};
export default MarkdownPreview;

View File

@ -0,0 +1,35 @@
import type { WidgetRulesFactory } from '../../../interface';
const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/;
const defaultWidgetRules: WidgetRulesFactory = ({ getAsset, field }) => [
{
rule: imageFilePattern,
toDOM(text) {
const rule = imageFilePattern;
const matched = text.match(rule);
if (matched) {
if (matched?.length === 4) {
// Image
const img = document.createElement('img');
img.setAttribute('src', getAsset(matched[3], field).url);
img.setAttribute('style', 'width: 100%;');
img.innerHTML = 'test';
return img;
} else {
// File
const a = document.createElement('a');
a.setAttribute('target', '_blank');
a.setAttribute('href', matched[2]);
a.innerHTML = matched[1];
return a;
}
}
return document.createElement('div');
},
},
];
export default defaultWidgetRules;

View File

@ -0,0 +1,23 @@
import { useMemo } from 'react';
import { getMarkdownEditorOptions } from '../../../lib/registry';
const useEditorOptions = () => {
return useMemo(() => {
const {
initialEditType = 'wysiwyg',
height = '600px',
plugins = [],
...markdownEditorOptions
} = getMarkdownEditorOptions();
return {
initialEditType,
height,
plugins,
...markdownEditorOptions,
};
}, []);
};
export default useEditorOptions;

View File

@ -0,0 +1,32 @@
import { useMemo } from 'react';
import useImageToolbarButton from '../toolbar/useImageToolbarButton';
import type { MarkdownEditorOptions } from '../../../interface';
const useToolbarItems = (
toolbarItems: MarkdownEditorOptions['toolbarItems'],
openMediaLibrary: (forImage: boolean) => void,
) => {
const imageToolbarButton = useImageToolbarButton({
openMediaLibrary,
});
return useMemo(() => {
let items = [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'],
['table', imageToolbarButton, 'link'],
['code', 'codeblock'],
];
if (toolbarItems) {
items = toolbarItems({ imageToolbarButton });
}
return items;
}, [imageToolbarButton, toolbarItems]);
};
export default useToolbarItems;

View File

@ -0,0 +1,20 @@
import { useMemo } from 'react';
import defaultWidgetRules from '../config/widgetRules';
import type { WidgetRulesFactory, WidgetRulesFactoryProps } from '../../../interface';
const useWidgetRules = (
widgetRules: WidgetRulesFactory | undefined,
{ getAsset, field }: WidgetRulesFactoryProps,
) => {
return useMemo(() => {
const rules = defaultWidgetRules({ getAsset, field });
if (widgetRules) {
rules.push(...widgetRules({ getAsset, field }));
}
return rules;
}, [field, getAsset, widgetRules]);
};
export default useWidgetRules;

View File

@ -1,40 +0,0 @@
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
// import { getEditorComponents } from '../../../lib/registry';
import type { Pluggable } from 'unified';
import type { GetAssetFunction } from '../../interface';
interface MarkdownToHtmlProps {
getAsset: GetAssetFunction;
remarkPlugins?: Pluggable[];
}
/**
* Convert Markdown to HTML.
*/
export function markdownToHtml(
markdown: string,
{ remarkPlugins = [] }: MarkdownToHtmlProps,
): string {
const html = unified()
.use(remarkParse)
.use(remarkGfm)
// .use(remarkParseShortcodes as any, { plugins: getEditorComponents() })
.use(remarkPlugins)
// .use(remarkToRehypeShortcodes as any, { plugins: getEditorComponents(), getAsset })
.use(remarkRehype, { allowDangerousHTML: true })
.use(rehypeStringify, {
allowDangerousHtml: true,
allowDangerousCharacters: true,
closeSelfClosing: true,
entities: { useNamedReferences: true },
})
.processSync(markdown);
return String(html);
}

View File

@ -7,7 +7,7 @@ export interface ImagePluginProps {
const PREFIX = 'toastui-editor-';
const useImagePlugin = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOptions => {
const useImageToolbarButton = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOptions => {
const toolbarButton = useMemo(() => {
const btn = document.createElement('button');
btn.type = 'button';
@ -41,4 +41,4 @@ const useImagePlugin = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOpti
return toolbarItem;
};
export default useImagePlugin;
export default useImageToolbarButton;