feat: add markdown shortcodes (#215)

This commit is contained in:
Daniel Lautzenheiser 2022-12-11 09:03:53 -05:00 committed by GitHub
parent 388de1e0c4
commit bb84382f6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 4229 additions and 399 deletions

View File

@ -79,4 +79,4 @@ jobs:
- name: Test - name: Test
working-directory: ./core working-directory: ./core
run: | run: |
yarn test yarn test:ci

View File

@ -174,13 +174,13 @@ widget: 'markdown',
## Formatting ## Formatting
<font style={{ color: 'red', backgroundColor: 'black' }}>Colored Text</font> **Bold**, *Italic*, ***both***
<p align="center">Centered Text</p> ~~Strikethrough~~
**Bold**, *Italic*, ***both***, <u>Underlined</u> ## Shortcodes
~~Strikethrough~~, <sub>subscript</sub>, <sup>superscript</sup> [youtube|p6h-rYSVX90]
## Support ## Support

View File

@ -118,3 +118,56 @@ CMS.registerAdditionalLink({
icon: 'page', icon: 'page',
}, },
}); });
CMS.registerShortcode('youtube', {
label: 'YouTube',
openTag: '[',
closeTag: ']',
separator: '|',
toProps: args => {
if (args.length > 0) {
return { src: args[0] };
}
return { src: '' };
},
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange }) => {
return h('span', {}, [
h('input', {
key: 'control-input',
value: src,
onChange: event => {
onChange({ src: event.target.value });
},
}),
h(
'iframe',
{
key: 'control-preview',
width: '420',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
]);
},
preview: ({ src }) => {
return h(
'span',
{},
h(
'iframe',
{
width: '420',
height: '315',
src: `https://www.youtube.com/embed/${src}`,
},
'',
),
);
},
});

View File

@ -12,6 +12,6 @@ module.exports = {
...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }), ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
'\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts', '\\.(css|less)$': '<rootDir>/src/__mocks__/styleMock.ts',
}, },
transformIgnorePatterns: ['node_modules/(?!(url-join|array-move|ol)/)'], transformIgnorePatterns: [],
setupFiles: ['./test/setupEnv.js'], setupFiles: ['./test/setupEnv.js'],
}; };

View File

@ -25,6 +25,7 @@
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
"start": "run-s clean develop", "start": "run-s clean develop",
"test": "cross-env NODE_ENV=test jest", "test": "cross-env NODE_ENV=test jest",
"test:ci": "cross-env NODE_ENV=test jest --maxWorkers=2",
"type-check": "tsc --watch" "type-check": "tsc --watch"
}, },
"main": "dist/static-cms-core.js", "main": "dist/static-cms-core.js",

View File

@ -0,0 +1,45 @@
import {
ELEMENT_BLOCKQUOTE,
ELEMENT_CODE_BLOCK,
ELEMENT_H1,
ELEMENT_H2,
ELEMENT_H3,
ELEMENT_H4,
ELEMENT_H5,
ELEMENT_H6,
ELEMENT_IMAGE,
ELEMENT_LI,
ELEMENT_LIC,
ELEMENT_LINK,
ELEMENT_OL,
ELEMENT_PARAGRAPH,
ELEMENT_TABLE,
ELEMENT_TD,
ELEMENT_TH,
ELEMENT_TR,
ELEMENT_UL,
} from '@udecode/plate';
export {
ELEMENT_BLOCKQUOTE,
ELEMENT_CODE_BLOCK,
ELEMENT_H1,
ELEMENT_H2,
ELEMENT_H3,
ELEMENT_H4,
ELEMENT_H5,
ELEMENT_H6,
ELEMENT_IMAGE,
ELEMENT_LI,
ELEMENT_LIC,
ELEMENT_LINK,
ELEMENT_OL,
ELEMENT_PARAGRAPH,
ELEMENT_TABLE,
ELEMENT_TD,
ELEMENT_TH,
ELEMENT_TR,
ELEMENT_UL,
};
export default {};

View File

@ -18,6 +18,7 @@ import {
ListWidget, ListWidget,
MapWidget, MapWidget,
MarkdownWidget, MarkdownWidget,
MdxWidget,
NumberWidget, NumberWidget,
ObjectWidget, ObjectWidget,
RelationWidget, RelationWidget,
@ -44,6 +45,7 @@ export default function addExtensions() {
ListWidget(), ListWidget(),
MapWidget(), MapWidget(),
MarkdownWidget(), MarkdownWidget(),
MdxWidget(),
NumberWidget(), NumberWidget(),
ObjectWidget(), ObjectWidget(),
RelationWidget(), RelationWidget(),

View File

@ -889,6 +889,26 @@ export interface MarkdownEditorOptions {
plugins?: MarkdownPluginFactory[]; plugins?: MarkdownPluginFactory[];
} }
export type ShortcodeControlProps<P = {}> = P & {
onChange: (props: P) => void;
controlProps: WidgetControlProps<string, MarkdownField>;
};
export type ShortcodePreviewProps<P = {}> = P & {
previewProps: WidgetPreviewProps<string, MarkdownField>;
};
export interface ShortcodeConfig<P = {}> {
label?: string;
openTag: string;
closeTag: string;
separator: string;
toProps?: (args: string[]) => P;
toArgs?: (props: P) => string[];
control: ComponentType<ShortcodeControlProps>;
preview: ComponentType<ShortcodePreviewProps>;
}
export enum CollectionType { export enum CollectionType {
FOLDER, FOLDER,
FILES, FILES,

View File

@ -14,11 +14,11 @@ import type {
EventListener, EventListener,
Field, Field,
LocalePhrasesRoot, LocalePhrasesRoot,
MarkdownEditorOptions,
MediaLibraryExternalLibrary, MediaLibraryExternalLibrary,
MediaLibraryOptions, MediaLibraryOptions,
PreviewStyle, PreviewStyle,
PreviewStyleOptions, PreviewStyleOptions,
ShortcodeConfig,
TemplatePreviewComponent, TemplatePreviewComponent,
UnknownField, UnknownField,
Widget, Widget,
@ -48,7 +48,7 @@ interface Registry {
previewStyles: PreviewStyle[]; previewStyles: PreviewStyle[];
/** Markdown editor */ /** Markdown editor */
markdownEditorConfig: MarkdownEditorOptions; shortcodes: Record<string, ShortcodeConfig>;
} }
/** /**
@ -65,7 +65,7 @@ const registry: Registry = {
locales: {}, locales: {},
eventHandlers, eventHandlers,
previewStyles: [], previewStyles: [],
markdownEditorConfig: {}, shortcodes: {},
}; };
export default { export default {
@ -93,6 +93,9 @@ export default {
getAdditionalLinks, getAdditionalLinks,
registerPreviewStyle, registerPreviewStyle,
getPreviewStyles, getPreviewStyles,
registerShortcode,
getShortcode,
getShortcodes,
}; };
/** /**
@ -133,7 +136,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
options?: WidgetOptions<T, F>, options?: WidgetOptions<T, F>,
): void; ): void;
export function registerWidget<T = unknown, F extends BaseField = UnknownField>( export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
name: string | WidgetParam<T, F> | WidgetParam[], nameOrWidgetOrWidgets: string | WidgetParam<T, F> | WidgetParam[],
control?: string | Widget<T, F>['control'], control?: string | Widget<T, F>['control'],
preview?: Widget<T, F>['preview'], preview?: Widget<T, F>['preview'],
{ {
@ -143,22 +146,22 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
getDefaultValue, getDefaultValue,
}: WidgetOptions<T, F> = {}, }: WidgetOptions<T, F> = {},
): void { ): void {
if (Array.isArray(name)) { if (Array.isArray(nameOrWidgetOrWidgets)) {
name.forEach(widget => { nameOrWidgetOrWidgets.forEach(widget => {
if (typeof widget !== 'object') { if (typeof widget !== 'object') {
console.error(`Cannot register widget: ${widget}`); console.error(`Cannot register widget: ${widget}`);
} else { } else {
registerWidget(widget); registerWidget(widget);
} }
}); });
} else if (typeof name === 'string') { } else if (typeof nameOrWidgetOrWidgets === 'string') {
// A registered widget control can be reused by a new widget, allowing // A registered widget control can be reused by a new widget, allowing
// multiple copies with different previews. // multiple copies with different previews.
const newControl = ( const newControl = (
typeof control === 'string' ? registry.widgets[control]?.control : control typeof control === 'string' ? registry.widgets[control]?.control : control
) as Widget['control']; ) as Widget['control'];
if (newControl) { if (newControl) {
registry.widgets[name] = { registry.widgets[nameOrWidgetOrWidgets] = {
control: newControl, control: newControl,
preview: preview as Widget['preview'], preview: preview as Widget['preview'],
validator: validator as Widget['validator'], validator: validator as Widget['validator'],
@ -167,7 +170,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
schema, schema,
}; };
} }
} else if (typeof name === 'object') { } else if (typeof nameOrWidgetOrWidgets === 'object') {
const { const {
name: widgetName, name: widgetName,
controlComponent: control, controlComponent: control,
@ -178,7 +181,7 @@ export function registerWidget<T = unknown, F extends BaseField = UnknownField>(
getDefaultValue, getDefaultValue,
schema, schema,
} = {}, } = {},
} = name; } = nameOrWidgetOrWidgets;
if (registry.widgets[widgetName]) { if (registry.widgets[widgetName]) {
console.warn(oneLine` console.warn(oneLine`
Multiple widgets registered with name "${widgetName}". Only the last widget registered with Multiple widgets registered with name "${widgetName}". Only the last widget registered with
@ -369,12 +372,20 @@ export function getAdditionalLink(id: string): AdditionalLink | undefined {
} }
/** /**
* Markdown editor options * Markdown editor shortcodes
*/ */
export function setMarkdownEditorOptions(options: MarkdownEditorOptions) { export function registerShortcode(name: string, config: ShortcodeConfig) {
registry.markdownEditorConfig = options; if (registry.backends[name]) {
console.error(`Shortcode [${name}] already registered. Please choose a different name.`);
return;
}
registry.shortcodes[name] = config;
} }
export function getMarkdownEditorOptions(): MarkdownEditorOptions { export function getShortcode(name: string): ShortcodeConfig {
return registry.markdownEditorConfig; return registry.shortcodes[name];
}
export function getShortcodes(): Record<string, ShortcodeConfig> {
return registry.shortcodes;
} }

View File

@ -16,6 +16,8 @@ export * from './map';
export { default as MapWidget } from './map'; export { default as MapWidget } from './map';
export * from './markdown'; export * from './markdown';
export { default as MarkdownWidget } from './markdown'; export { default as MarkdownWidget } from './markdown';
export * from './mdx';
export { default as MdxWidget } from './mdx';
export * from './number'; export * from './number';
export { default as NumberWidget } from './number'; export { default as NumberWidget } from './number';
export * from './object'; export * from './object';

View File

@ -1,113 +0,0 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
import Outline from '@staticcms/core/components/UI/Outline';
import useDebounce from '../../lib/hooks/useDebounce';
import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
import PlateEditor from './plate/PlateEditor';
import serialize from './plate/serialization/serializerMarkdown';
import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { MdValue } from './plate/plateTypes';
import type { BlockType, LeafType } from './plate/serialization/slate/ast-types';
const StyledEditorWrapper = styled('div')`
position: relative;
width: 100%;
.toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor {
width: 100%;
}
.toastui-editor-main .toastui-editor-md-splitter {
display: none;
}
.toastui-editor-md-preview {
display: none;
}
.toastui-editor-defaultUI {
border: none;
}
`;
const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = ({
label,
value,
onChange,
hasErrors,
collection,
entry,
field,
}) => {
const [internalValue, setInternalValue] = useState(value ?? '');
const [hasFocus, setHasFocus] = useState(false);
const debouncedFocus = useDebounce(hasFocus, 150);
const handleOnFocus = useCallback(() => {
setHasFocus(true);
}, []);
const handleOnBlur = useCallback(() => {
setHasFocus(false);
}, []);
const handleOnChange = useCallback(
(slateValue: MdValue) => {
const newValue = slateValue.map(v => serialize(v as BlockType | LeafType)).join('\n');
// console.log('[Plate] slateValue', slateValue, 'newMarkdownValue', newValue);
if (newValue !== internalValue) {
setInternalValue(newValue);
onChange(newValue);
}
},
[internalValue, onChange],
);
const handleLabelClick = useCallback(() => {
// editorRef.current?.getInstance().focus();
}, []);
const [slateValue, loaded] = useMarkdownToSlate(internalValue);
// console.log('[Plate] slateValue', slateValue);
return useMemo(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
{loaded ? (
<PlateEditor
initialValue={slateValue}
collection={collection}
entry={entry}
field={field}
onChange={handleOnChange}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
/>
) : null}
<Outline
key="markdown-control-outline"
hasLabel
hasError={hasErrors}
active={hasFocus || debouncedFocus}
/>
</StyledEditorWrapper>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasErrors, hasFocus, label, loaded, slateValue],
);
};
export default MarkdownControl;

View File

@ -3,10 +3,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { VFileMessage } from 'vfile-message'; import { VFileMessage } from 'vfile-message';
import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer'; import WidgetPreviewContainer from '@staticcms/core/components/UI/WidgetPreviewContainer';
import { getShortcodes } from '../../lib/registry';
import withShortcodeMdxComponent from './mdx/withShortcodeMdxComponent';
import useMdx from './plate/hooks/useMdx'; import useMdx from './plate/hooks/useMdx';
import { processShortcodeConfigToMdx } from './plate/serialization/slate/processShortcodeConfig';
import type { FC } from 'react';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface'; import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
interface FallbackComponentProps { interface FallbackComponentProps {
error: string; error: string;
@ -22,18 +25,23 @@ function FallbackComponent({ error }: FallbackComponentProps) {
); );
} }
const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = ({ value }) => { const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = previewProps => {
useEffect(() => { const { value } = previewProps;
// viewer.current?.getInstance().setMarkdown(value ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps const components = useMemo(
}, [value]); () => ({
Shortcode: withShortcodeMdxComponent({ previewProps }),
}),
[previewProps],
);
const [state, setValue] = useMdx(value ?? ''); const [state, setValue] = useMdx(value ?? '');
const [prevValue, setPrevValue] = useState(value); const [prevValue, setPrevValue] = useState('');
useEffect(() => { useEffect(() => {
if (prevValue !== value) { if (prevValue !== value) {
setPrevValue(value ?? ''); const parsedValue = processShortcodeConfigToMdx(getShortcodes(), value ?? '');
setValue(value ?? ''); setPrevValue(parsedValue);
setValue(parsedValue);
} }
}, [prevValue, setValue, value]); }, [prevValue, setValue, value]);
@ -50,8 +58,6 @@ const MarkdownPreview: FC<WidgetPreviewProps<string, MarkdownField>> = ({ value
} }
}, [state.file]); }, [state.file]);
const components = useMemo(() => ({}), []);
return useMemo(() => { return useMemo(() => {
if (!value) { if (!value) {
return null; return null;

View File

@ -1,9 +1,11 @@
import controlComponent from './MarkdownControl'; import withMarkdownControl from './withMarkdownControl';
import previewComponent from './MarkdownPreview'; import previewComponent from './MarkdownPreview';
import schema from './schema'; import schema from './schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface'; import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withMarkdownControl({ useMdx: false });
const MarkdownWidget = (): WidgetParam<string, MarkdownField> => { const MarkdownWidget = (): WidgetParam<string, MarkdownField> => {
return { return {
name: 'markdown', name: 'markdown',

View File

@ -0,0 +1,2 @@
export * from './withShortcodeMdxComponent';
export { default as withShortcodeElement } from './withShortcodeMdxComponent';

View File

@ -0,0 +1,36 @@
import React, { useMemo } from 'react';
import { getShortcode } from '../../../lib/registry';
import type { MarkdownField, WidgetPreviewProps } from '@staticcms/core/interface';
import type { FC } from 'react';
export interface WithShortcodeMdxComponentProps {
previewProps: WidgetPreviewProps<string, MarkdownField>;
}
interface ShortcodeMdxComponentProps {
shortcode: string;
args: string[];
}
const withShortcodeMdxComponent = ({ previewProps }: WithShortcodeMdxComponentProps) => {
const ShortcodeMdxComponent: FC<ShortcodeMdxComponentProps> = ({ shortcode, args }) => {
const config = useMemo(() => getShortcode(shortcode), [shortcode]);
const [ShortcodePreview, props] = useMemo(() => {
if (!config) {
return [null, {}];
}
const props = config.toProps ? config.toProps(args) : {};
return [config.preview, props];
}, [config, args]);
return ShortcodePreview ? <ShortcodePreview previewProps={previewProps} {...props} /> : null;
};
return ShortcodeMdxComponent;
};
export default withShortcodeMdxComponent;

View File

@ -51,11 +51,12 @@ import {
withProps, withProps,
} from '@udecode/plate'; } from '@udecode/plate';
import { StyledLeaf } from '@udecode/plate-styled-components'; import { StyledLeaf } from '@udecode/plate-styled-components';
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef } from 'react';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { withShortcodeElement } from './components';
import { BalloonToolbar } from './components/balloon-toolbar'; import { BalloonToolbar } from './components/balloon-toolbar';
import { BlockquoteElement } from './components/nodes/blockquote'; import { BlockquoteElement } from './components/nodes/blockquote';
import { CodeBlockElement } from './components/nodes/code-block'; import { CodeBlockElement } from './components/nodes/code-block';
@ -75,11 +76,11 @@ import {
OrderedListElement, OrderedListElement,
UnorderedListElement, UnorderedListElement,
} from './components/nodes/list'; } from './components/nodes/list';
import Paragraph from './components/nodes/paragraph/Paragraph'; import ParagraphElement from './components/nodes/paragraph/ParagraphElement';
import { TableCellElement, TableElement, TableRowElement } from './components/nodes/table'; import { TableCellElement, TableElement, TableRowElement } from './components/nodes/table';
import { Toolbar } from './components/toolbar'; import { Toolbar } from './components/toolbar';
import editableProps from './editableProps'; import editableProps from './editableProps';
import { createMdPlugins } from './plateTypes'; import { createMdPlugins, ELEMENT_SHORTCODE } from './plateTypes';
import { alignPlugin } from './plugins/align'; import { alignPlugin } from './plugins/align';
import { autoformatPlugin } from './plugins/autoformat'; import { autoformatPlugin } from './plugins/autoformat';
import { createCodeBlockPlugin } from './plugins/code-block'; import { createCodeBlockPlugin } from './plugins/code-block';
@ -87,12 +88,18 @@ import { CursorOverlayContainer } from './plugins/cursor-overlay';
import { exitBreakPlugin } from './plugins/exit-break'; import { exitBreakPlugin } from './plugins/exit-break';
import { createListPlugin } from './plugins/list'; import { createListPlugin } from './plugins/list';
import { resetBlockTypePlugin } from './plugins/reset-node'; import { resetBlockTypePlugin } from './plugins/reset-node';
import { createShortcodePlugin } from './plugins/shortcode';
import { softBreakPlugin } from './plugins/soft-break'; import { softBreakPlugin } from './plugins/soft-break';
import { createTablePlugin } from './plugins/table'; import { createTablePlugin } from './plugins/table';
import { trailingBlockPlugin } from './plugins/trailing-block'; import { trailingBlockPlugin } from './plugins/trailing-block';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type {
import type { AutoformatPlugin } from '@udecode/plate'; Collection,
Entry,
MarkdownField,
WidgetControlProps,
} from '@staticcms/core/interface';
import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate';
import type { CSSProperties, FC } from 'react'; import type { CSSProperties, FC } from 'react';
import type { MdEditor, MdValue } from './plateTypes'; import type { MdEditor, MdValue } from './plateTypes';
@ -112,6 +119,8 @@ export interface PlateEditorProps {
collection: Collection<MarkdownField>; collection: Collection<MarkdownField>;
entry: Entry; entry: Entry;
field: MarkdownField; field: MarkdownField;
useMdx: boolean;
controlProps: WidgetControlProps<string, MarkdownField>;
onChange: (value: MdValue) => void; onChange: (value: MdValue) => void;
onFocus: () => void; onFocus: () => void;
onBlur: () => void; onBlur: () => void;
@ -122,6 +131,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
collection, collection,
entry, entry,
field, field,
useMdx,
controlProps,
onChange, onChange,
onFocus, onFocus,
onBlur, onBlur,
@ -130,15 +141,15 @@ const PlateEditor: FC<PlateEditorProps> = ({
const editorContainerRef = useRef<HTMLDivElement | null>(null); const editorContainerRef = useRef<HTMLDivElement | null>(null);
const innerEditorContainerRef = useRef<HTMLDivElement | null>(null); const innerEditorContainerRef = useRef<HTMLDivElement | null>(null);
const components = useMemo( const components = useMemo(() => {
() => ({ const baseComponents = {
[ELEMENT_H1]: Heading1, [ELEMENT_H1]: Heading1,
[ELEMENT_H2]: Heading2, [ELEMENT_H2]: Heading2,
[ELEMENT_H3]: Heading3, [ELEMENT_H3]: Heading3,
[ELEMENT_H4]: Heading4, [ELEMENT_H4]: Heading4,
[ELEMENT_H5]: Heading5, [ELEMENT_H5]: Heading5,
[ELEMENT_H6]: Heading6, [ELEMENT_H6]: Heading6,
[ELEMENT_PARAGRAPH]: Paragraph, [ELEMENT_PARAGRAPH]: ParagraphElement,
[ELEMENT_TABLE]: TableElement, [ELEMENT_TABLE]: TableElement,
[ELEMENT_TR]: TableRowElement, [ELEMENT_TR]: TableRowElement,
[ELEMENT_TH]: TableCellElement, [ELEMENT_TH]: TableCellElement,
@ -161,81 +172,92 @@ const PlateEditor: FC<PlateEditorProps> = ({
[ELEMENT_UL]: UnorderedListElement, [ELEMENT_UL]: UnorderedListElement,
[ELEMENT_LI]: ListItemElement, [ELEMENT_LI]: ListItemElement,
[ELEMENT_LIC]: ListItemContentElement, [ELEMENT_LIC]: ListItemContentElement,
[ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
[MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }), [MARK_BOLD]: withProps(StyledLeaf, { as: 'strong' }),
[MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }), [MARK_ITALIC]: withProps(StyledLeaf, { as: 'em' }),
[MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }), [MARK_STRIKETHROUGH]: withProps(StyledLeaf, { as: 's' }),
[MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }), };
[MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
[MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
}),
[collection, entry, field],
);
const [hasEditorFocus, setHasEditorFocus] = useState(false); if (useMdx) {
// MDX Widget
return {
...baseComponents,
[MARK_SUBSCRIPT]: withProps(StyledLeaf, { as: 'sub' }),
[MARK_SUPERSCRIPT]: withProps(StyledLeaf, { as: 'sup' }),
[MARK_UNDERLINE]: withProps(StyledLeaf, { as: 'u' }),
};
}
const handleOnFocus = useCallback(() => { // Markdown widget
setHasEditorFocus(true); return {
onFocus(); ...baseComponents,
}, [onFocus]); [ELEMENT_SHORTCODE]: withShortcodeElement({ controlProps }),
};
}, [collection, controlProps, entry, field, useMdx]);
const handleOnBlur = useCallback(() => { const plugins = useMemo(() => {
setHasEditorFocus(false); const basePlugins: PlatePlugin<AnyObject, MdValue>[] = [
onBlur(); createParagraphPlugin(),
}, [onBlur]); createBlockquotePlugin(),
createTodoListPlugin(),
createHeadingPlugin(),
createImagePlugin(),
// createHorizontalRulePlugin(),
createLinkPlugin(),
createListPlugin(),
createTablePlugin(),
// createMediaEmbedPlugin(),
createCodeBlockPlugin(),
createBoldPlugin(),
createCodePlugin(),
createItalicPlugin(),
// createHighlightPlugin(),
createStrikethroughPlugin(),
// createFontSizePlugin(),
// createKbdPlugin(),
// createNodeIdPlugin(),
// createDndPlugin({ options: { enableScroller: true } }),
// dragOverCursorPlugin,
// createIndentPlugin(indentPlugin),
createAutoformatPlugin<AutoformatPlugin<MdValue, MdEditor>, MdValue, MdEditor>(
autoformatPlugin,
),
createResetNodePlugin(resetBlockTypePlugin),
createSoftBreakPlugin(softBreakPlugin),
createExitBreakPlugin(exitBreakPlugin),
createTrailingBlockPlugin(trailingBlockPlugin),
// createSelectOnBackspacePlugin(selectOnBackspacePlugin),
// createComboboxPlugin(),
// createMentionPlugin(),
// createDeserializeMdPlugin(),
// createDeserializeCsvPlugin(),
// createDeserializeDocxPlugin(),
// createJuicePlugin() as MdPlatePlugin,
];
const plugins = useMemo( if (useMdx) {
() => // MDX Widget
createMdPlugins( return createMdPlugins(
[ [
createParagraphPlugin(), ...basePlugins,
createBlockquotePlugin(),
createTodoListPlugin(),
createHeadingPlugin(),
createImagePlugin(),
// createHorizontalRulePlugin(),
createLinkPlugin(),
createListPlugin(),
createTablePlugin(),
// createMediaEmbedPlugin(),
createCodeBlockPlugin(),
createAlignPlugin(alignPlugin),
createBoldPlugin(),
createCodePlugin(),
createItalicPlugin(),
// createHighlightPlugin(),
createUnderlinePlugin(),
createStrikethroughPlugin(),
createSubscriptPlugin(),
createSuperscriptPlugin(),
createFontColorPlugin(), createFontColorPlugin(),
createFontBackgroundColorPlugin(), createFontBackgroundColorPlugin(),
// createFontSizePlugin(), createSubscriptPlugin(),
// createKbdPlugin(), createSuperscriptPlugin(),
// createNodeIdPlugin(), createUnderlinePlugin(),
// createDndPlugin({ options: { enableScroller: true } }), createAlignPlugin(alignPlugin),
// dragOverCursorPlugin,
// createIndentPlugin(indentPlugin),
createAutoformatPlugin<AutoformatPlugin<MdValue, MdEditor>, MdValue, MdEditor>(
autoformatPlugin,
),
createResetNodePlugin(resetBlockTypePlugin),
createSoftBreakPlugin(softBreakPlugin),
createExitBreakPlugin(exitBreakPlugin),
createTrailingBlockPlugin(trailingBlockPlugin),
// createSelectOnBackspacePlugin(selectOnBackspacePlugin),
// createComboboxPlugin(),
// createMentionPlugin(),
// createDeserializeMdPlugin(),
// createDeserializeCsvPlugin(),
// createDeserializeDocxPlugin(),
// createJuicePlugin() as MdPlatePlugin,
], ],
{ {
components, components,
}, },
), );
[components], }
);
// Markdown Widget
return createMdPlugins([...basePlugins, createShortcodePlugin()], {
components,
});
}, [components, useMdx]);
const id = useUUID(); const id = useUUID();
@ -253,6 +275,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
<div key="editor-outer_wrapper" ref={outerEditorContainerRef} style={styles.container}> <div key="editor-outer_wrapper" ref={outerEditorContainerRef} style={styles.container}>
<Toolbar <Toolbar
key="toolbar" key="toolbar"
useMdx={useMdx}
containerRef={outerEditorContainerRef.current} containerRef={outerEditorContainerRef.current}
collection={collection} collection={collection}
field={field} field={field}
@ -265,8 +288,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
id={id} id={id}
editableProps={{ editableProps={{
...editableProps, ...editableProps,
onFocus: handleOnFocus, onFocus,
onBlur: handleOnBlur, onBlur,
}} }}
> >
<div <div
@ -276,8 +299,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
> >
<BalloonToolbar <BalloonToolbar
key="balloon-toolbar" key="balloon-toolbar"
useMdx={useMdx}
containerRef={innerEditorContainerRef.current} containerRef={innerEditorContainerRef.current}
hasEditorFocus={hasEditorFocus}
collection={collection} collection={collection}
field={field} field={field}
entry={entry} entry={entry}
@ -292,7 +315,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
</StyledPlateEditor> </StyledPlateEditor>
), ),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[collection, field, handleOnBlur, handleOnFocus, initialValue, onChange, plugins], [collection, field, onBlur, onFocus, initialValue, onChange, plugins],
); );
}; };

View File

@ -16,6 +16,7 @@ import {
someNode, someNode,
usePlateSelection, usePlateSelection,
} from '@udecode/plate'; } from '@udecode/plate';
import { useFocused } from 'slate-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useDebounce from '@staticcms/core/lib/hooks/useDebounce'; import useDebounce from '@staticcms/core/lib/hooks/useDebounce';
@ -25,6 +26,7 @@ import BasicElementToolbarButtons from '../buttons/BasicElementToolbarButtons';
import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons'; import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import MediaToolbarButtons from '../buttons/MediaToolbarButtons'; import MediaToolbarButtons from '../buttons/MediaToolbarButtons';
import TableToolbarButtons from '../buttons/TableToolbarButtons'; import TableToolbarButtons from '../buttons/TableToolbarButtons';
import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
import type { ClientRectObject } from '@udecode/plate'; import type { ClientRectObject } from '@udecode/plate';
@ -54,20 +56,21 @@ const StyledDivider = styled('div')(
); );
export interface BalloonToolbarProps { export interface BalloonToolbarProps {
useMdx: boolean;
containerRef: HTMLElement | null; containerRef: HTMLElement | null;
hasEditorFocus: boolean;
collection: Collection<MarkdownField>; collection: Collection<MarkdownField>;
field: MarkdownField; field: MarkdownField;
entry: Entry; entry: Entry;
} }
const BalloonToolbar: FC<BalloonToolbarProps> = ({ const BalloonToolbar: FC<BalloonToolbarProps> = ({
useMdx,
containerRef, containerRef,
hasEditorFocus,
collection, collection,
field, field,
entry, entry,
}) => { }) => {
const hasEditorFocus = useFocused();
const editor = useMdPlateEditorState(); const editor = useMdPlateEditorState();
const selection = usePlateSelection(); const selection = usePlateSelection();
const [hasFocus, setHasFocus] = useState(false); const [hasFocus, setHasFocus] = useState(false);
@ -126,9 +129,10 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
return []; return [];
} }
// Selected text buttons
if (selectionText && selectionExpanded) { if (selectionText && selectionExpanded) {
return [ return [
<BasicMarkToolbarButtons key="selection-basic-mark-buttons" />, <BasicMarkToolbarButtons key="selection-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons <BasicElementToolbarButtons
key="selection-basic-element-buttons" key="selection-basic-element-buttons"
hideFontTypeSelect={isInTableCell} hideFontTypeSelect={isInTableCell}
@ -147,6 +151,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
].filter(Boolean); ].filter(Boolean);
} }
// Empty paragraph, not first line
if ( if (
editor.children.length > 1 && editor.children.length > 1 &&
node && node &&
@ -164,7 +169,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
parent[0].children.length === 1 parent[0].children.length === 1
) { ) {
return [ return [
<BasicMarkToolbarButtons key="empty-basic-mark-buttons" />, <BasicMarkToolbarButtons key="empty-basic-mark-buttons" useMdx={useMdx} />,
<BasicElementToolbarButtons <BasicElementToolbarButtons
key="empty-basic-element-buttons" key="empty-basic-element-buttons"
hideFontTypeSelect={isInTableCell} hideFontTypeSelect={isInTableCell}
@ -179,6 +184,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
entry={entry} entry={entry}
onMediaToggle={setMediaOpen} onMediaToggle={setMediaOpen}
/>, />,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
]; ];
} }
} }
@ -186,18 +192,20 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
return []; return [];
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
collection, mediaOpen,
editor, debouncedEditorFocus,
field,
hasFocus, hasFocus,
debouncedHasFocus, debouncedHasFocus,
debouncedEditorFocus,
isInTableCell,
mediaOpen,
node,
selection, selection,
selectionExpanded, editor,
selectionText, selectionText,
selectionExpanded,
node,
useMdx,
isInTableCell,
containerRef,
collection,
field,
]); ]);
const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState( const [prevSelectionBoundingClientRect, setPrevSelectionBoundingClientRect] = useState(
@ -243,7 +251,7 @@ const BalloonToolbar: FC<BalloonToolbarProps> = ({
> >
<StyledPopperContent> <StyledPopperContent>
{(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [ {(groups.length > 0 ? groups : debouncedGroups).map((group, index) => [
index !== 0 ? <StyledDivider key={`table-divider-${index}`} /> : null, index !== 0 ? <StyledDivider key={`balloon-toolbar-divider-${index}`} /> : null,
group, group,
])} ])}
</StyledPopperContent> </StyledPopperContent>

View File

@ -10,9 +10,24 @@ import type { FC } from 'react';
const AlignToolbarButtons: FC = () => { const AlignToolbarButtons: FC = () => {
return ( return (
<> <>
<AlignToolbarButton tooltip="Align Left" value="left" icon={<FormatAlignLeftIcon />} /> <AlignToolbarButton
<AlignToolbarButton tooltip="Align Center" value="center" icon={<FormatAlignCenterIcon />} /> key="algin-button-left"
<AlignToolbarButton tooltip="Align Right" value="right" icon={<FormatAlignRightIcon />} /> tooltip="Align Left"
value="left"
icon={<FormatAlignLeftIcon />}
/>
<AlignToolbarButton
key="algin-button-center"
tooltip="Align Center"
value="center"
icon={<FormatAlignCenterIcon />}
/>
<AlignToolbarButton
key="algin-button-right"
tooltip="Align Right"
value="right"
icon={<FormatAlignRightIcon />}
/>
</> </>
); );
}; };

View File

@ -22,33 +22,42 @@ import type { FC } from 'react';
export interface BasicMarkToolbarButtonsProps { export interface BasicMarkToolbarButtonsProps {
extended?: boolean; extended?: boolean;
useMdx: boolean;
} }
const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({ extended = false }) => { const BasicMarkToolbarButtons: FC<BasicMarkToolbarButtonsProps> = ({
extended = false,
useMdx,
}) => {
return ( return (
<> <>
<MarkToolbarButton tooltip="Bold" type={MARK_BOLD} icon={<FormatBoldIcon />} /> <MarkToolbarButton tooltip="Bold" type={MARK_BOLD} icon={<FormatBoldIcon />} />
<MarkToolbarButton tooltip="Italic" type={MARK_ITALIC} icon={<FormatItalicIcon />} /> <MarkToolbarButton tooltip="Italic" type={MARK_ITALIC} icon={<FormatItalicIcon />} />
<MarkToolbarButton {useMdx ? (
tooltip="Underline" <MarkToolbarButton
type={MARK_UNDERLINE} key="underline-button"
icon={<FormatUnderlinedIcon />} tooltip="Underline"
/> type={MARK_UNDERLINE}
icon={<FormatUnderlinedIcon />}
/>
) : null}
<MarkToolbarButton <MarkToolbarButton
tooltip="Strikethrough" tooltip="Strikethrough"
type={MARK_STRIKETHROUGH} type={MARK_STRIKETHROUGH}
icon={<FormatStrikethroughIcon />} icon={<FormatStrikethroughIcon />}
/> />
<MarkToolbarButton tooltip="Code" type={MARK_CODE} icon={<CodeIcon />} /> <MarkToolbarButton tooltip="Code" type={MARK_CODE} icon={<CodeIcon />} />
{extended ? ( {useMdx && extended ? (
<> <>
<MarkToolbarButton <MarkToolbarButton
key="superscript-button"
tooltip="Superscript" tooltip="Superscript"
type={MARK_SUPERSCRIPT} type={MARK_SUPERSCRIPT}
clear={MARK_SUBSCRIPT} clear={MARK_SUBSCRIPT}
icon={<SuperscriptIcon />} icon={<SuperscriptIcon />}
/> />
<MarkToolbarButton <MarkToolbarButton
key="subscript-button"
tooltip="Subscript" tooltip="Subscript"
type={MARK_SUBSCRIPT} type={MARK_SUBSCRIPT}
clear={MARK_SUPERSCRIPT} clear={MARK_SUPERSCRIPT}

View File

@ -0,0 +1,73 @@
import DataArrayIcon from '@mui/icons-material/DataArray';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { focusEditor, insertNodes } from '@udecode/plate-core';
import React, { useCallback, useMemo, useState } from 'react';
import { getShortcodes } from '../../../../../lib/registry';
import { toTitleCase } from '../../../../../lib/util/string.util';
import { ELEMENT_SHORTCODE, useMdPlateEditorState } from '../../plateTypes';
import ToolbarButton from './common/ToolbarButton';
import type { FC, MouseEvent } from 'react';
import type { MdEditor } from '../../plateTypes';
const ShortcodeToolbarButton: FC = () => {
const editor = useMdPlateEditorState();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = useCallback((_editor: MdEditor, event: MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const configs = useMemo(() => getShortcodes(), []);
const handleShortcodeClick = useCallback(
(shortcode: string) => () => {
insertNodes(editor, {
type: ELEMENT_SHORTCODE,
shortcode,
args: [],
children: [{ text: '' }],
});
focusEditor(editor);
handleClose();
},
[editor, handleClose],
);
return (
<>
<ToolbarButton
key="shortcode-button"
tooltip="Add Shortcode"
icon={<DataArrayIcon />}
onClick={handleClick}
/>
<Menu
id="shortcode-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'shortcode-button',
}}
>
{Object.keys(configs).map(name => {
const config = configs[name];
return (
<MenuItem key={`shortcode-${name}`} onClick={handleShortcodeClick(name)}>
{config.label ?? toTitleCase(name)}
</MenuItem>
);
})}
</Menu>
</>
);
};
export default ShortcodeToolbarButton;

View File

@ -154,6 +154,7 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
useEffect(() => { useEffect(() => {
if ( if (
anchorEl &&
!debouncedHasEditorFocus && !debouncedHasEditorFocus &&
!hasEditorFocus && !hasEditorFocus &&
!hasFocus && !hasFocus &&
@ -163,6 +164,7 @@ const MediaPopover = <T extends FileOrImageField | MarkdownField>({
handleClose(false); handleClose(false);
} }
}, [ }, [
anchorEl,
debouncedHasEditorFocus, debouncedHasEditorFocus,
debouncedHasFocus, debouncedHasFocus,
handleClose, handleClose,

View File

@ -117,7 +117,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
}, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]); }, [handleClose, hasEditorFocus, element, selection, editor, handleOpenPopover]);
return ( return (
<div onBlur={handleBlur}> <span onBlur={handleBlur}>
<img <img
ref={imageRef} ref={imageRef}
src={assetSource} src={assetSource}
@ -142,7 +142,7 @@ const withImageElement = ({ containerRef, collection, entry, field }: WithImageE
forImage forImage
/> />
{children} {children}
</div> </span>
); );
}; };

View File

@ -7,4 +7,5 @@ export * from './image';
export * from './link'; export * from './link';
export * from './list'; export * from './list';
export * from './paragraph'; export * from './paragraph';
export * from './shortcode';
export * from './table'; export * from './table';

View File

@ -4,11 +4,11 @@ import type { MdParagraphElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate'; import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react'; import type { FC } from 'react';
const Paragraph: FC<PlateRenderElementProps<MdValue, MdParagraphElement>> = ({ const ParagraphElement: FC<PlateRenderElementProps<MdValue, MdParagraphElement>> = ({
children, children,
element: { align }, element: { align },
}) => { }) => {
return <p style={{ textAlign: align }}>{children}</p>; return <p style={{ textAlign: align }}>{children}</p>;
}; };
export default Paragraph; export default ParagraphElement;

View File

@ -1,2 +1,2 @@
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
export { default as Paragraph } from './Paragraph'; export { default as Paragraph } from './ParagraphElement';

View File

@ -0,0 +1,2 @@
export * from './withShortcodeElement';
export { default as withShortcodeElement } from './withShortcodeElement';

View File

@ -0,0 +1,57 @@
import { findNodePath, setNodes } from '@udecode/plate';
import React, { useCallback, useMemo } from 'react';
import { getShortcode } from '../../../../../../lib/registry';
import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
import type { MdShortcodeElement, MdValue } from '@staticcms/markdown';
import type { PlateRenderElementProps } from '@udecode/plate';
import type { FC } from 'react';
export interface WithShortcodeElementProps {
controlProps: WidgetControlProps<string, MarkdownField>;
}
const withShortcodeElement = ({ controlProps }: WithShortcodeElementProps) => {
const ShortcodeElement: FC<PlateRenderElementProps<MdValue, MdShortcodeElement>> = ({
element,
editor,
children,
}) => {
const config = useMemo(() => getShortcode(element.shortcode), [element.shortcode]);
const [ShortcodeControl, props] = useMemo(() => {
if (!config) {
return [null, {}];
}
const props = config.toProps ? config.toProps(element.args) : {};
return [config.control, props];
}, [config, element.args]);
const handleOnChange = useCallback(
(props: {}) => {
if (!config || !config.toArgs) {
return;
}
const path = findNodePath(editor, element);
path && setNodes(editor, { args: config.toArgs(props) }, { at: path });
},
[config, editor, element],
);
return (
<span contentEditable={false}>
{ShortcodeControl ? (
<ShortcodeControl controlProps={controlProps} onChange={handleOnChange} {...props} />
) : null}
{children}
</span>
);
};
return ShortcodeElement;
};
export default withShortcodeElement;

View File

@ -7,6 +7,7 @@ import BasicMarkToolbarButtons from '../buttons/BasicMarkToolbarButtons';
import ColorToolbarButtons from '../buttons/ColorToolbarButtons'; import ColorToolbarButtons from '../buttons/ColorToolbarButtons';
import ListToolbarButtons from '../buttons/ListToolbarButtons'; import ListToolbarButtons from '../buttons/ListToolbarButtons';
import MediaToolbarButton from '../buttons/MediaToolbarButtons'; import MediaToolbarButton from '../buttons/MediaToolbarButtons';
import ShortcodeToolbarButton from '../buttons/ShortcodeToolbarButton';
import type { FC } from 'react'; import type { FC } from 'react';
import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface'; import type { Collection, Entry, MarkdownField } from '@staticcms/core/interface';
@ -42,32 +43,36 @@ const StyledDivider = styled('div')(
); );
export interface ToolbarProps { export interface ToolbarProps {
useMdx: boolean;
containerRef: HTMLElement | null; containerRef: HTMLElement | null;
collection: Collection<MarkdownField>; collection: Collection<MarkdownField>;
field: MarkdownField; field: MarkdownField;
entry: Entry; entry: Entry;
} }
const Toolbar: FC<ToolbarProps> = ({ containerRef, collection, field, entry }) => { const Toolbar: FC<ToolbarProps> = ({ useMdx, containerRef, collection, field, entry }) => {
const groups = [
<BasicMarkToolbarButtons key="basic-mark-buttons" useMdx={useMdx} extended />,
<BasicElementToolbarButtons key="basic-element-buttons" />,
<ListToolbarButtons key="list-buttons" />,
useMdx ? <ColorToolbarButtons key="color-buttons" /> : null,
useMdx ? <AlignToolbarButtons key="align-mark-buttons" /> : null,
<MediaToolbarButton
key="media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
/>,
!useMdx ? <ShortcodeToolbarButton key="shortcode-button" /> : null,
].filter(Boolean);
return ( return (
<StyledToolbar> <StyledToolbar>
<BasicMarkToolbarButtons key="basic-mark-buttons" extended /> {groups.map((group, index) => [
<StyledDivider /> index !== 0 ? <StyledDivider key={`toolbar-divider-${index}`} /> : null,
<BasicElementToolbarButtons key="basic-element-buttons" /> group,
<StyledDivider /> ])}
<ListToolbarButtons key="list-buttons" />
<StyledDivider />
<ColorToolbarButtons key="color-buttons" />
<StyledDivider />
<AlignToolbarButtons key="align-mark-buttons" />
<StyledDivider />
<MediaToolbarButton
key="media-buttons"
containerRef={containerRef}
collection={collection}
field={field}
entry={entry}
/>
</StyledToolbar> </StyledToolbar>
); );
}; };

View File

@ -0,0 +1,50 @@
import {
deserializationOnlyTestData,
runSerializationTests,
testShortcodeConfigs as shortcodeConfigs,
} from '../../tests-util/serializationTests.util';
import { markdownToSlate } from '../useMarkdownToSlate';
import type { SerializationTestData } from '../../tests-util/serializationTests.util';
import type { UseMarkdownToSlateOptions } from '../useMarkdownToSlate';
async function expectNodes(
markdown: string,
options: UseMarkdownToSlateOptions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: any[],
) {
expect(await markdownToSlate(markdown, options)).toEqual(children);
}
function sanitizeHtmlInMarkdown(markdown: string) {
return markdown
.replace('</font>', '<\\/font>')
.replace('<u>', '<u\\>')
.replace('</u>', '<\\/u>')
.replace('<sub>', '<sub\\>')
.replace('</sub>', '<\\/sub>')
.replace('<sup>', '<sup\\>')
.replace('</sup>', '<\\/sup>');
}
function testRunner(key: string, mode: 'markdown' | 'mdx' | 'both', data: SerializationTestData) {
it(`deserializes ${key}`, async () => {
if (mode === 'both') {
await expectNodes(data.markdown, { shortcodeConfigs, useMdx: false }, data.slate);
await expectNodes(data.markdown, { shortcodeConfigs, useMdx: true }, data.slate);
return;
}
await expectNodes(
mode === 'markdown' ? sanitizeHtmlInMarkdown(data.markdown) : data.markdown,
{ shortcodeConfigs, useMdx: mode === 'mdx' },
data.slate,
);
});
}
describe('markdownToSlate', () => {
runSerializationTests(testRunner);
runSerializationTests(testRunner, deserializationOnlyTestData);
});

View File

@ -1,3 +1,4 @@
export * from './useMarkdownToSlate';
export { default as useMarkdownToSlate } from './useMarkdownToSlate'; export { default as useMarkdownToSlate } from './useMarkdownToSlate';
export * from './useMdx'; export * from './useMdx';
export { default as useMdx } from './useMdx'; export { default as useMdx } from './useMdx';

View File

@ -0,0 +1,61 @@
import { ELEMENT_PARAGRAPH } from '@udecode/plate';
import { useEffect, useState } from 'react';
import gfm from 'remark-gfm';
import mdx from 'remark-mdx';
import markdown from 'remark-parse';
import { unified } from 'unified';
import { getShortcodes } from '../../../../lib/registry';
import toSlatePlugin from '../serialization/slate/toSlatePlugin';
import type { ShortcodeConfig } from '../../../../interface';
import type { MdValue } from '../plateTypes';
export interface UseMarkdownToSlateOptions {
shortcodeConfigs?: Record<string, ShortcodeConfig>;
useMdx: boolean;
}
export const markdownToSlate = async (
markdownValue: string,
{ useMdx, shortcodeConfigs }: UseMarkdownToSlateOptions,
) => {
return new Promise<MdValue>(resolve => {
unified()
.use(markdown)
.use(gfm)
// eslint-disable-next-line @typescript-eslint/no-empty-function
.use(useMdx ? mdx : () => {})
.use(toSlatePlugin({ shortcodeConfigs: shortcodeConfigs ?? getShortcodes(), useMdx }))
.process(markdownValue, (err, file) => {
if (err) {
console.error(err);
return;
}
resolve(file?.result as MdValue);
});
});
};
const useMarkdownToSlate = (
markdownValue: string,
options: UseMarkdownToSlateOptions,
): [MdValue, boolean] => {
const [loaded, setLoaded] = useState(false);
const [slateValue, setSlateValue] = useState<MdValue>([]);
useEffect(() => {
markdownToSlate(markdownValue, options).then(value => {
setSlateValue(value);
setLoaded(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [
slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }],
loaded,
];
};
export default useMarkdownToSlate;

View File

@ -1,40 +0,0 @@
import { ELEMENT_PARAGRAPH } from '@udecode/plate';
import { useEffect, useState } from 'react';
import gfm from 'remark-gfm';
import mdx from 'remark-mdx';
import markdown from 'remark-parse';
import { unified } from 'unified';
import toSlatePlugin from '../serialization/slate/toSlatePlugin';
import type { MdValue } from '../plateTypes';
const useMarkdownToSlate = (markdownValue: string): [MdValue, boolean] => {
const [loaded, setLoaded] = useState(false);
const [slateValue, setSlateValue] = useState<MdValue>([]);
useEffect(() => {
unified()
.use(markdown)
.use(gfm)
.use(mdx)
.use(toSlatePlugin)
.process(markdownValue, (err, file) => {
if (err) {
console.error(err);
return;
}
setSlateValue(file?.result as MdValue);
setLoaded(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return [
slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }],
loaded,
];
};
export default useMarkdownToSlate;

View File

@ -1,4 +1,5 @@
import { evaluate } from '@mdx-js/mdx'; import { evaluate } from '@mdx-js/mdx';
import * as provider from '@mdx-js/react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import * as runtime from 'react/jsx-runtime'; import * as runtime from 'react/jsx-runtime';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@ -20,6 +21,7 @@ export default function useMdx(input: string): [UseMdxState, (value: string) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = { const options: any = {
...provider,
...runtime, ...runtime,
useDynamicImport: true, useDynamicImport: true,
remarkPlugins: [remarkGfm, flattenListItemParagraphs], remarkPlugins: [remarkGfm, flattenListItemParagraphs],

View File

@ -35,6 +35,7 @@ import type {
ELEMENT_HR, ELEMENT_HR,
ELEMENT_IMAGE, ELEMENT_IMAGE,
ELEMENT_LI, ELEMENT_LI,
ELEMENT_LIC,
ELEMENT_LINK, ELEMENT_LINK,
ELEMENT_MEDIA_EMBED, ELEMENT_MEDIA_EMBED,
ELEMENT_MENTION, ELEMENT_MENTION,
@ -79,6 +80,8 @@ import type {
} from '@udecode/plate'; } from '@udecode/plate';
import type { CSSProperties } from 'styled-components'; import type { CSSProperties } from 'styled-components';
export const ELEMENT_SHORTCODE = 'shortcode' as const;
/** /**
* Text * Text
*/ */
@ -125,7 +128,12 @@ export interface MdMentionElement extends TMentionElement {
children: [EmptyText]; children: [EmptyText];
} }
export type MdInlineElement = MdLinkElement | MdMentionElement | MdMentionInputElement; export type MdInlineElement =
| MdImageElement
| MdLinkElement
| MdMentionElement
| MdMentionInputElement
| MdShortcodeElement;
export type MdInlineDescendant = MdInlineElement | RichText; export type MdInlineDescendant = MdInlineElement | RichText;
export type MdInlineChildren = MdInlineDescendant[]; export type MdInlineChildren = MdInlineDescendant[];
@ -165,6 +173,13 @@ export interface MdParagraphElement extends MdBlockElement {
align?: 'left' | 'center' | 'right'; align?: 'left' | 'center' | 'right';
} }
export interface MdShortcodeElement extends TElement {
type: typeof ELEMENT_SHORTCODE;
shortcode: string;
args: string[];
children: [EmptyText];
}
export interface MdH1Element extends MdBlockElement { export interface MdH1Element extends MdBlockElement {
type: typeof ELEMENT_H1; type: typeof ELEMENT_H1;
children: MdInlineChildren; children: MdInlineChildren;
@ -202,7 +217,7 @@ export interface MdBlockquoteElement extends MdBlockElement {
export interface MdCodeBlockElement extends MdBlockElement { export interface MdCodeBlockElement extends MdBlockElement {
type: typeof ELEMENT_CODE_BLOCK; type: typeof ELEMENT_CODE_BLOCK;
lang: string | undefined; lang: string | undefined | null;
code: string; code: string;
} }
@ -239,6 +254,12 @@ export interface MdNumberedListElement extends TElement, MdBlockElement {
export interface MdListItemElement extends TElement, MdBlockElement { export interface MdListItemElement extends TElement, MdBlockElement {
type: typeof ELEMENT_LI; type: typeof ELEMENT_LI;
checked: boolean | null; checked: boolean | null;
children: MdListItemContentElement[];
}
export interface MdListItemContentElement extends TElement, MdBlockElement {
type: typeof ELEMENT_LIC;
checked: boolean | null;
children: MdInlineChildren; children: MdInlineChildren;
} }

View File

@ -7,6 +7,7 @@ export * from './indent';
export * from './list'; export * from './list';
export * from './reset-node'; export * from './reset-node';
export * from './select-on-backspace'; export * from './select-on-backspace';
export * from './shortcode';
export * from './soft-break'; export * from './soft-break';
export * from './table'; export * from './table';
export * from './trailing-block'; export * from './trailing-block';

View File

@ -0,0 +1,12 @@
import { createPluginFactory } from '@udecode/plate';
import { ELEMENT_SHORTCODE } from '../../plateTypes';
const createShortcodePlugin = createPluginFactory({
key: ELEMENT_SHORTCODE,
isElement: true,
isInline: true,
type: ELEMENT_SHORTCODE,
});
export default createShortcodePlugin;

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as createShortcodePlugin } from './createShortcodePlugin';

View File

@ -0,0 +1,26 @@
import {
runSerializationTests,
testShortcodeConfigs as shortcodeConfigs,
} from '../../tests-util/serializationTests.util';
import serializeMarkdown from '../serializeMarkdown';
import type { MdValue } from '../../plateTypes';
function expectMarkdown(nodes: MdValue, options: { useMdx: boolean }, markdown: string) {
const result = serializeMarkdown(nodes, { ...options, shortcodeConfigs });
expect(result).toBe(`${markdown}\n`);
}
describe('serializeMarkdown', () => {
runSerializationTests((key, mode, data) => {
it(`serializes ${key}`, async () => {
if (mode === 'both') {
await expectMarkdown(data.slate, { useMdx: false }, data.markdown);
await expectMarkdown(data.slate, { useMdx: true }, data.markdown);
return;
}
await expectMarkdown(data.slate, { useMdx: mode === 'mdx' }, data.markdown);
});
});
});

View File

@ -1,2 +1,3 @@
export * from './serializeMarkdown';
export { default as serializeMarkdown } from './serializeMarkdown';
export * from './slate'; export * from './slate';
export { default as serializerMarkdown } from './serializerMarkdown';

View File

@ -1,22 +1,24 @@
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
// import { BlockType, defaultNodeTypes, LeafType, NodeTypes } from './ast-types'; import { getShortcodes } from '../../../../lib/registry';
import escapeHtml from 'escape-html'; import { isEmpty } from '../../../../lib/util/string.util';
import { LIST_TYPES, NodeTypes } from './slate/ast-types'; import { LIST_TYPES, NodeTypes } from './slate/ast-types';
import type { CSSProperties } from 'react';
import type { ShortcodeConfig } from '../../../../interface';
import type { import type {
MdCodeBlockElement, MdCodeBlockElement,
MdImageElement, MdImageElement,
MdLinkElement, MdLinkElement,
MdListItemElement, MdListItemElement,
MdParagraphElement, MdParagraphElement,
MdShortcodeElement,
MdValue,
} from '../plateTypes'; } from '../plateTypes';
import type { TableNode, BlockType, LeafType } from './slate/ast-types'; import type { BlockType, LeafType, TableNode } from './slate/ast-types';
import type { CSSProperties } from 'react';
type FontStyles = Pick<CSSProperties, 'color' | 'backgroundColor' | 'textAlign'>; type FontStyles = Pick<CSSProperties, 'color' | 'backgroundColor' | 'textAlign'>;
interface MdLeafType extends LeafType { export interface MdLeafType extends LeafType {
superscript?: boolean; superscript?: boolean;
subscript?: boolean; subscript?: boolean;
underline?: boolean; underline?: boolean;
@ -24,39 +26,55 @@ interface MdLeafType extends LeafType {
backgroundColor?: string; backgroundColor?: string;
} }
interface MdBlockType extends Omit<BlockType, 'children'> { export interface MdBlockType extends Omit<BlockType, 'children'> {
children: Array<MdBlockType | MdLeafType>; children: Array<MdBlockType | MdLeafType>;
} }
interface Options { interface SerializeMarkdownNodeOptions {
isInTable?: boolean; isInTable?: boolean;
isInCode?: boolean; isInCode?: boolean;
listDepth?: number; listDepth?: number;
blockquoteDepth?: number; blockquoteDepth?: number;
ignoreParagraphNewline?: boolean; ignoreParagraphNewline?: boolean;
useMdx: boolean;
index: number;
shortcodeConfigs: Record<string, ShortcodeConfig>;
} }
const isLeafNode = (node: MdBlockType | MdLeafType): node is MdLeafType => { const isLeafNode = (node: MdBlockType | MdLeafType): node is MdLeafType => {
return typeof (node as MdLeafType).text === 'string'; return typeof (node as MdLeafType).text === 'string';
}; };
const VOID_ELEMENTS: Array<keyof typeof NodeTypes> = ['thematic_break', 'image', 'code_block']; const VOID_ELEMENTS: Array<keyof typeof NodeTypes> = [
'thematic_break',
'image',
'code_block',
'shortcode',
'tableCell',
'tableHeaderCell',
];
const BREAK_TAG = '<br />'; const BREAK_TAG = '<br />';
const CODE_ELEMENTS = [NodeTypes.code_block]; const CODE_ELEMENTS = [NodeTypes.code_block];
export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts: Options = {}) { function serializeMarkdownNode(
chunk: MdBlockType | MdLeafType,
opts: SerializeMarkdownNodeOptions,
) {
const { const {
ignoreParagraphNewline = false, ignoreParagraphNewline = false,
listDepth = 0, listDepth = 0,
isInTable = false, isInTable = false,
isInCode = false, isInCode = false,
blockquoteDepth = 0, blockquoteDepth = 0,
useMdx,
shortcodeConfigs,
} = opts; } = opts;
const text = (chunk as MdLeafType).text || ''; const text = (chunk as MdLeafType).text || '';
let type = (chunk as MdBlockType).type || ''; let type = (chunk as MdBlockType).type || '';
const selfIsBlockquote = 'type' in chunk && chunk.type === 'blockquote';
let children = text; let children = text;
@ -67,12 +85,11 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
} }
children = chunk.children children = chunk.children
.map((c: MdBlockType | MdLeafType) => { .map((c: MdBlockType | MdLeafType, childIndex) => {
const selfIsTable = type === NodeTypes.table; const selfIsTable = type === NodeTypes.table;
const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false; const isList = !isLeafNode(c) ? (LIST_TYPES as string[]).includes(c.type || '') : false;
const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || ''); const selfIsList = (LIST_TYPES as string[]).includes(chunk.type || '');
const selfIsCode = (CODE_ELEMENTS as string[]).includes(chunk.type || ''); const selfIsCode = (CODE_ELEMENTS as string[]).includes(chunk.type || '');
const selfIsBlockquote = chunk.type === 'blockquote';
// Links can have the following shape // Links can have the following shape
// In which case we don't want to surround // In which case we don't want to surround
@ -91,7 +108,7 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
childrenHasLink = chunk.children.some(f => !isLeafNode(f) && f.type === NodeTypes.link); childrenHasLink = chunk.children.some(f => !isLeafNode(f) && f.type === NodeTypes.link);
} }
return serializerMarkdown( return serializeMarkdownNode(
{ ...c, parentType: type }, { ...c, parentType: type },
{ {
// WOAH. // WOAH.
@ -102,20 +119,18 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
// of whitespace. If we're parallel to a link we also don't want // of whitespace. If we're parallel to a link we also don't want
// to respect neighboring paragraphs // to respect neighboring paragraphs
ignoreParagraphNewline: ignoreParagraphNewline:
(ignoreParagraphNewline || isList || selfIsList || childrenHasLink) && (ignoreParagraphNewline || isList || selfIsList || childrenHasLink || isInTable) &&
// if we have c.break, never ignore empty paragraph new line // if we have c.break, never ignore empty paragraph new line
!(c as MdBlockType).break, !(c as MdBlockType).break,
// track depth of nested lists so we can add proper spacing // track depth of nested lists so we can add proper spacing
listDepth: (LIST_TYPES as string[]).includes((c as MdBlockType).type || '') listDepth: selfIsList ? listDepth + 1 : listDepth,
? listDepth + 1
: listDepth,
isInTable: selfIsTable || isInTable, isInTable: selfIsTable || isInTable,
isInCode: selfIsCode || isInCode, isInCode: selfIsCode || isInCode,
blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth, blockquoteDepth: selfIsBlockquote ? blockquoteDepth + 1 : blockquoteDepth,
useMdx,
index: childIndex,
shortcodeConfigs,
}, },
); );
}) })
@ -127,7 +142,10 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
!ignoreParagraphNewline && !ignoreParagraphNewline &&
(text === '' || text === '\n') && (text === '' || text === '\n') &&
chunk.parentType === NodeTypes.paragraph && chunk.parentType === NodeTypes.paragraph &&
type !== NodeTypes.image type !== NodeTypes.image &&
type !== NodeTypes.shortcode &&
type !== NodeTypes.tableCell &&
type !== NodeTypes.tableHeaderCell
) { ) {
type = NodeTypes.paragraph; type = NodeTypes.paragraph;
children = '\n'; children = '\n';
@ -145,7 +163,6 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
// "Text foo bar **baz**" resulting in "**Text foo bar **baz****" // "Text foo bar **baz**" resulting in "**Text foo bar **baz****"
// which is invalid markup and can mess everything up // which is invalid markup and can mess everything up
if (children !== '\n' && isLeafNode(chunk)) { if (children !== '\n' && isLeafNode(chunk)) {
children = isInCode || chunk.code ? children : escapeHtml(children);
if (chunk.strikethrough && chunk.bold && chunk.italic) { if (chunk.strikethrough && chunk.bold && chunk.italic) {
children = retainWhitespaceAndFormat(children, '~~***'); children = retainWhitespaceAndFormat(children, '~~***');
} else if (chunk.bold && chunk.italic) { } else if (chunk.bold && chunk.italic) {
@ -220,46 +237,44 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
return `###### ${children}\n`; return `###### ${children}\n`;
case NodeTypes.block_quote: case NodeTypes.block_quote:
// For some reason, marked is parsing blockquotes w/ one new line return `${selfIsBlockquote && blockquoteDepth > 0 ? '\n' : ''}> ${children
// as contiued blockquotes, so adding two new lines ensures that doesn't
// happen
return `> ${children
.replace(/^[\n]*|[\n]*$/gm, '') .replace(/^[\n]*|[\n]*$/gm, '')
.split('\n') .split('\n')
.join('\n> ')}\n\n`; .join('\n> ')}\n`;
case NodeTypes.code_block: case NodeTypes.code_block:
const codeBlock = chunk as MdCodeBlockElement; const codeBlock = chunk as MdCodeBlockElement;
return `\`\`\`${codeBlock.lang ?? ''}\n${codeBlock.code}\n\`\`\`\n`; return `\`\`\`${codeBlock.lang ?? ''}\n${codeBlock.code}\n\`\`\`\n`;
case NodeTypes.link: case NodeTypes.link:
const linkBlock = chunk as unknown as MdLinkElement; const linkBlock = chunk as MdLinkElement;
return `[${children}](${linkBlock.url || ''})`; return `[${children}](${linkBlock.url || ''})`;
case NodeTypes.image: case NodeTypes.image:
const imageBlock = chunk as unknown as MdImageElement; const imageBlock = chunk as MdImageElement;
const caption = imageBlock.caption ?? []; const alt = imageBlock.alt ?? '';
return `![${caption.length > 0 ? caption[0].text ?? '' : ''}](${imageBlock.url || ''})`; return `![${alt}](${imageBlock.url || ''})`;
case NodeTypes.ul_list: case NodeTypes.ul_list:
case NodeTypes.ol_list: case NodeTypes.ol_list:
return `\n${children}`; return `${listDepth > 0 ? '\n' : ''}${children}`;
case NodeTypes.listItemContent: case NodeTypes.listItemContent:
return children; return children;
case NodeTypes.listItem: case NodeTypes.listItem:
const listItemBlock = chunk as unknown as MdListItemElement; const listItemBlock = chunk as MdListItemElement;
const isOL = chunk && chunk.parentType === NodeTypes.ol_list; const isOL = chunk && chunk.parentType === NodeTypes.ol_list;
const treatAsLeaf = const treatAsLeaf =
(chunk as MdBlockType).children.length >= 1 && (chunk as MdBlockType).children.length >= 1 &&
((chunk as MdBlockType).children.reduce((acc, child) => acc && isLeafNode(child), true) || ((chunk as MdBlockType).children.reduce((acc, child) => acc && isLeafNode(child), true) ||
((chunk as MdBlockType).children[0] as BlockType).type === 'lic'); ((chunk as MdBlockType).children.length === 1 &&
((chunk as MdBlockType).children[0] as BlockType).type === 'lic'));
let spacer = ''; let spacer = '';
for (let k = 0; listDepth > k; k++) { for (let k = 1; listDepth > k; k++) {
if (isOL) { if (isOL) {
// https://github.com/remarkjs/remark-react/issues/65 // https://github.com/remarkjs/remark-react/issues/65
spacer += ' '; spacer += ' ';
@ -270,15 +285,19 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
let checkbox = ''; let checkbox = '';
if (typeof listItemBlock.checked === 'boolean') { if (typeof listItemBlock.checked === 'boolean') {
checkbox = ` [${listItemBlock.checked ? 'X' : ' '}]`; checkbox = ` [${listItemBlock.checked ? 'x' : ' '}]`;
} }
return `${spacer}${isOL ? '1.' : '-'}${checkbox} ${children}${treatAsLeaf ? '\n' : ''}`; return `${spacer}${isOL ? '1.' : '-'}${checkbox} ${children}${treatAsLeaf ? '\n' : ''}`;
case NodeTypes.paragraph: case NodeTypes.paragraph:
const paragraphNode = chunk as unknown as MdParagraphElement; const paragraphNode = chunk as MdParagraphElement;
if (paragraphNode.align) { if (useMdx && paragraphNode.align) {
return `<p style={{ textAlign: '${paragraphNode.align}' }}>${children}</p>`; return retainWhitespaceAndFormat(
children,
`<p style={{ textAlign: '${paragraphNode.align}' }}>`,
'</p>\n',
);
} }
return `${children}${!isInTable ? '\n' : ''}`; return `${children}${!isInTable ? '\n' : ''}`;
@ -287,15 +306,31 @@ export default function serializerMarkdown(chunk: MdBlockType | MdLeafType, opts
case NodeTypes.table: case NodeTypes.table:
const columns = getTableColumnCount(chunk as TableNode); const columns = getTableColumnCount(chunk as TableNode);
return `|${Array(columns).fill(' ').join('|')}| const rows = children.split('\n');
const header = rows.length > 0 ? rows[0] : `|${Array(columns).fill(' ').join('|')}|`;
const bodyRows = rows.slice(1);
return `${header}
|${Array(columns).fill('---').join('|')}| |${Array(columns).fill('---').join('|')}|
${children}\n`; ${bodyRows.join('\n')}`;
case NodeTypes.tableRow: case NodeTypes.tableRow:
return `|${children}|\n`; return `|${children}|\n`;
case NodeTypes.tableHeaderCell:
case NodeTypes.tableCell: case NodeTypes.tableCell:
return children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG); return isEmpty(children) ? ' ' : children.replace(/\|/g, '\\|').replace(/\n/g, BREAK_TAG);
case NodeTypes.shortcode:
const shortcodeNode = chunk as MdShortcodeElement;
const shortcodeConfig = shortcodeConfigs[shortcodeNode.shortcode];
if (!shortcodeConfig) {
return children;
}
return `${shortcodeConfig.openTag}${[shortcodeNode.shortcode, ...shortcodeNode.args].join(
shortcodeConfig.separator,
)}${shortcodeConfig.closeTag}`;
default: default:
console.warn('Unrecognized slate node, proceeding as text', `"${type}"`, chunk); console.warn('Unrecognized slate node, proceeding as text', `"${type}"`, chunk);
@ -343,3 +378,23 @@ function getTableColumnCount(tableNode: TableNode): number {
return rows[0].children.length; return rows[0].children.length;
} }
export interface SerializeMarkdownOptions {
useMdx: boolean;
shortcodeConfigs?: Record<string, ShortcodeConfig<{}>>;
}
export default function serializeMarkdown(
slateValue: MdValue,
{ useMdx, shortcodeConfigs }: SerializeMarkdownOptions,
) {
return slateValue
.map((v, index) =>
serializeMarkdownNode(v as BlockType | LeafType, {
useMdx,
index,
shortcodeConfigs: shortcodeConfigs ?? getShortcodes(),
}),
)
.join('\n');
}

View File

@ -0,0 +1,90 @@
import { processShortcodeConfigToMdx } from '../processShortcodeConfig';
import { testShortcodeConfigs } from '../../../tests-util/serializationTests.util';
describe('processShortcodeConfig', () => {
describe('processShortcodeConfigToMdx', () => {
it('converts to mdx', () => {
const markdown = '[youtube|p6h-rYSVX90]';
const mdx = '<Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('converts shortcode with no args', () => {
const markdown = '[youtube]';
const mdx = '<Shortcode shortcode="youtube" args={[]} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('converts shortcode with multiple args', () => {
const markdown = '[youtube|p6h-rYSVX90|somethingElse|andOneMore]';
const mdx =
"<Shortcode shortcode=\"youtube\" args={['p6h-rYSVX90', 'somethingElse', 'andOneMore']} />";
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text before', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90]';
const mdx = 'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text after', () => {
const markdown = '[youtube|p6h-rYSVX90] and text after';
const mdx = '<Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and text after';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('shortcode with text before and after', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90] and text after';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and text after';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('multiple shortcodes', () => {
const markdown = 'Text before [youtube|p6h-rYSVX90] and {{< twitter 917359331535966209 >}}';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} /> and <Shortcode shortcode="twitter" args={[\'917359331535966209\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('multiple of the same shortcodes', () => {
const markdown =
'Text before [youtube|p6h-rYSVX90], [youtube|p6h-rYSVX90], {{< twitter 917359331535966209 >}} and [youtube|p6h-rYSVX90]';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, <Shortcode shortcode="twitter" args={[\'917359331535966209\']} /> and <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('unrecognized shortcode', () => {
const markdown = '[someOtherShortcode|andstuff]';
const mdx = '[someOtherShortcode|andstuff]';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('unrecognized shortcode surrounded by recognized shortcodes', () => {
const markdown =
'Text before [youtube|p6h-rYSVX90], [someOtherShortcode|andstuff] and {{< twitter 917359331535966209 >}}';
const mdx =
'Text before <Shortcode shortcode="youtube" args={[\'p6h-rYSVX90\']} />, [someOtherShortcode|andstuff] and <Shortcode shortcode="twitter" args={[\'917359331535966209\']} />';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
it('plain text', () => {
const markdown = 'Some text about something going on somewhere';
const mdx = 'Some text about something going on somewhere';
expect(processShortcodeConfigToMdx(testShortcodeConfigs, markdown)).toBe(mdx);
});
});
});

View File

@ -15,10 +15,13 @@ import {
ELEMENT_PARAGRAPH, ELEMENT_PARAGRAPH,
ELEMENT_TABLE, ELEMENT_TABLE,
ELEMENT_TD, ELEMENT_TD,
ELEMENT_TH,
ELEMENT_TR, ELEMENT_TR,
ELEMENT_UL, ELEMENT_UL,
} from '@udecode/plate'; } from '@udecode/plate';
import { ELEMENT_SHORTCODE } from '../../plateTypes';
export const VOID_ELEMENTS = [ELEMENT_CODE_BLOCK, ELEMENT_IMAGE]; export const VOID_ELEMENTS = [ELEMENT_CODE_BLOCK, ELEMENT_IMAGE];
export const MarkNodeTypes = { export const MarkNodeTypes = {
@ -39,6 +42,7 @@ export const NodeTypes = {
table: ELEMENT_TABLE, table: ELEMENT_TABLE,
tableRow: ELEMENT_TR, tableRow: ELEMENT_TR,
tableCell: ELEMENT_TD, tableCell: ELEMENT_TD,
tableHeaderCell: ELEMENT_TH,
heading: { heading: {
1: ELEMENT_H1, 1: ELEMENT_H1,
2: ELEMENT_H2, 2: ELEMENT_H2,
@ -47,6 +51,7 @@ export const NodeTypes = {
5: ELEMENT_H5, 5: ELEMENT_H5,
6: ELEMENT_H6, 6: ELEMENT_H6,
}, },
shortcode: ELEMENT_SHORTCODE,
emphasis_mark: 'italic', emphasis_mark: 'italic',
strong_mark: 'bold', strong_mark: 'bold',
delete_mark: 'strikethrough', delete_mark: 'strikethrough',
@ -87,13 +92,19 @@ export interface BlockType {
type: string; type: string;
parentType?: string; parentType?: string;
link?: string; link?: string;
caption?: string; alt?: string;
language?: string; language?: string;
break?: boolean; break?: boolean;
children: Array<BlockType | LeafType>; children: Array<BlockType | LeafType>;
} }
export type MdastNode = BaseMdastNode | MdxMdastNode; export interface ShortcodeNode extends BaseMdastNode {
type: 'shortcode';
shortcode: string;
args: string[];
}
export type MdastNode = BaseMdastNode | MdxMdastNode | ShortcodeNode;
export interface BaseMdastNode { export interface BaseMdastNode {
type?: Omit<MdastNodeType, 'mdxJsxTextElement'>; type?: Omit<MdastNodeType, 'mdxJsxTextElement'>;
@ -114,6 +125,7 @@ export interface BaseMdastNode {
checked?: any; checked?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
indent?: any; indent?: any;
align?: (string | null)[];
} }
export interface MdxMdastNodeAttributeValue { export interface MdxMdastNodeAttributeValue {
@ -150,8 +162,6 @@ export interface MdxMdastNode extends BaseMdastNode {
attributes?: MdxMdastNodeAttribute[]; attributes?: MdxMdastNodeAttribute[];
} }
export const allowedStyles: string[] = ['color', 'backgroundColor'];
export interface TextNodeStyles { export interface TextNodeStyles {
color?: string; color?: string;
backgroundColor?: string; backgroundColor?: string;
@ -208,7 +218,7 @@ export type ImageNode = {
type: typeof NodeTypes['image']; type: typeof NodeTypes['image'];
children: Array<DeserializedNode>; children: Array<DeserializedNode>;
url: string | undefined; url: string | undefined;
caption: TextNode; alt: string | undefined;
}; };
export type TableNode = { export type TableNode = {

View File

@ -1,8 +1,10 @@
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
import { ELEMENT_PARAGRAPH } from '@udecode/plate'; import { ELEMENT_PARAGRAPH } from '@udecode/plate';
import { allowedStyles, LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types'; import { LIST_TYPES, MarkNodeTypes, NodeTypes } from './ast-types';
import { processShortcodeConfigToSlate } from './processShortcodeConfig';
import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { MdBlockElement } from '@staticcms/markdown'; import type { MdBlockElement } from '@staticcms/markdown';
import type { import type {
AlignMdxMdastNodeAttribute, AlignMdxMdastNodeAttribute,
@ -18,6 +20,7 @@ import type {
ListNode, ListNode,
MarkNode, MarkNode,
MdastNode, MdastNode,
MdxMdastNode,
ParagraphNode, ParagraphNode,
StyleMdxMdastNodeAttribute, StyleMdxMdastNodeAttribute,
TextNode, TextNode,
@ -54,21 +57,62 @@ function mdxToMark(mark: keyof typeof MarkNodeTypes, children: DeserializedNode[
} as MarkNode; } as MarkNode;
} }
export interface Options { function parseStyleAttribute(node: MdxMdastNode, allowedStyles: Record<string, string>) {
isInTable?: boolean; const styleAttribute = node.attributes?.find(
a => a.name === 'style',
) as StyleMdxMdastNodeAttribute;
const nodeStyles: TextNodeStyles = {};
if (styleAttribute) {
let styles: Record<string, string> = {};
try {
styles =
JSON.parse(
styleAttribute.value.value
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ')
.replace(/:[ ]*[']([^']+)[']/g, ': "$1"'),
) ?? {};
} catch (e) {
console.error(`Error parsing font styles (${styleAttribute.value.value})`, e);
}
Object.keys(styles).map(key => {
if (key in allowedStyles) {
nodeStyles[allowedStyles[key] as keyof TextNodeStyles] = styles[key];
}
});
}
return nodeStyles;
} }
export default function deserializeMarkdown(node: MdastNode, options?: Options) { export interface Options {
isInTable?: boolean;
isInTableHeaderRow?: boolean;
tableAlign?: (string | null)[];
useMdx: boolean;
shortcodeConfigs: Record<string, ShortcodeConfig>;
index: number;
}
export default function deserializeMarkdown(node: MdastNode, options: Options) {
let children: Array<DeserializedNode> = [{ text: '' }]; let children: Array<DeserializedNode> = [{ text: '' }];
const { isInTable = false } = options ?? {}; const {
isInTable = false,
isInTableHeaderRow = false,
tableAlign,
useMdx,
shortcodeConfigs,
index,
} = options ?? {};
const selfIsTable = node.type === 'table'; const selfIsTable = node.type === 'table';
const selfIsTableHeaderRow = node.type === 'tableRow' && index === 0;
const nodeChildren = node.children; const nodeChildren = node.children;
if (nodeChildren && Array.isArray(nodeChildren) && nodeChildren.length > 0) { if (nodeChildren && Array.isArray(nodeChildren) && nodeChildren.length > 0) {
children = nodeChildren.flatMap( children = nodeChildren.flatMap(
(c: MdastNode) => (c: MdastNode, childIndex) =>
deserializeMarkdown( deserializeMarkdown(
{ {
...c, ...c,
@ -76,6 +120,11 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
}, },
{ {
isInTable: selfIsTable || isInTable, isInTable: selfIsTable || isInTable,
isInTableHeaderRow: selfIsTableHeaderRow || isInTableHeaderRow,
useMdx,
shortcodeConfigs,
index: childIndex,
tableAlign: tableAlign || (selfIsTable ? node.align : undefined),
}, },
) as DeserializedNode, ) as DeserializedNode,
); );
@ -152,7 +201,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
type: NodeTypes.image, type: NodeTypes.image,
children: [{ text: '' }], children: [{ text: '' }],
url: node.url, url: node.url,
caption: [{ text: node.alt ?? '' }], alt: node.alt,
} as ImageNode; } as ImageNode;
case 'blockquote': case 'blockquote':
@ -213,7 +262,23 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return { type: NodeTypes.tableRow, children }; return { type: NodeTypes.tableRow, children };
case 'tableCell': case 'tableCell':
return { type: NodeTypes.tableCell, children: [{ type: NodeTypes.paragraph, children }] }; return {
type: isInTableHeaderRow ? NodeTypes.tableHeaderCell : NodeTypes.tableCell,
children: [{ type: NodeTypes.paragraph, children }],
};
case 'mdxJsxFlowElement':
if ('name' in node) {
switch (node.name) {
case 'br':
return { type: NodeTypes.paragraph, children: [{ text: '' }] };
default:
console.warn('unrecognized mdx flow element', node);
break;
}
}
return { text: node.value || '' };
case 'mdxJsxTextElement': case 'mdxJsxTextElement':
if ('name' in node) { if ('name' in node) {
@ -227,6 +292,8 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
case 'u': case 'u':
return mdxToMark('underline_mark', children); return mdxToMark('underline_mark', children);
case 'p': case 'p':
const paragraphNodeStyles = parseStyleAttribute(node, { textAlign: 'align' });
const alignAttribute = node.attributes?.find( const alignAttribute = node.attributes?.find(
a => a.name === 'align', a => a.name === 'align',
) as AlignMdxMdastNodeAttribute; ) as AlignMdxMdastNodeAttribute;
@ -237,6 +304,7 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return { return {
type: NodeTypes.paragraph, type: NodeTypes.paragraph,
...paragraphNodeStyles,
...pNodeStyles, ...pNodeStyles,
children: [ children: [
{ {
@ -246,44 +314,25 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
], ],
} as ParagraphNode; } as ParagraphNode;
case 'font': case 'font':
const styleAttribute = node.attributes?.find( const fontNodeStyles = parseStyleAttribute(node, {
a => a.name === 'style', color: 'color',
) as StyleMdxMdastNodeAttribute; backgroundColor: 'backgroundColor',
const nodeStyles: TextNodeStyles = {}; });
if (styleAttribute) {
let styles: Record<string, string> = {};
try {
styles =
JSON.parse(
styleAttribute.value.value
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2": ')
.replace(/:[ ]*[']([^']+)[']/g, ': "$1"'),
) ?? {};
} catch (e) {
console.error(`Error parsing font styles (${styleAttribute.value.value})`, e);
}
Object.keys(styles).map(key => {
if (allowedStyles.includes(key)) {
nodeStyles[key as keyof TextNodeStyles] = styles[key];
}
});
}
const colorAttribute = node.attributes?.find( const colorAttribute = node.attributes?.find(
a => a.name === 'color', a => a.name === 'color',
) as ColorMdxMdastNodeAttribute; ) as ColorMdxMdastNodeAttribute;
if (colorAttribute) { if (colorAttribute) {
nodeStyles.color = colorAttribute.value; fontNodeStyles.color = colorAttribute.value;
} }
return { return {
...nodeStyles, ...fontNodeStyles,
...forceLeafNode(children as Array<TextNode>), ...forceLeafNode(children as Array<TextNode>),
...persistLeafFormats(children as Array<MdastNode>), ...persistLeafFormats(children as Array<MdastNode>),
} as TextNode; } as TextNode;
default: default:
console.warn('unrecognized mdx node', node); console.warn('unrecognized mdx text element', node);
break; break;
} }
} }
@ -291,7 +340,22 @@ export default function deserializeMarkdown(node: MdastNode, options?: Options)
return { text: node.value || '' }; return { text: node.value || '' };
case 'text': case 'text':
return { text: node.value || '' }; if (useMdx) {
return { text: node.value || '' };
}
if (!node.value) {
return { text: '' };
}
let nodes: MdastNode[] = [node];
for (const shortcode in shortcodeConfigs) {
nodes = processShortcodeConfigToSlate(shortcode, shortcodeConfigs[shortcode], nodes);
}
return nodes.map(node => (node.type === 'text' ? { text: node.value ?? '' } : node));
default: default:
console.warn('Unrecognized mdast node, proceeding as text', node); console.warn('Unrecognized mdast node, proceeding as text', node);
return { text: node.value || '' }; return { text: node.value || '' };

View File

@ -2,4 +2,5 @@ export * from './ast-types';
export * from './deserializeMarkdown'; export * from './deserializeMarkdown';
export { default as deserializeMarkdown } from './deserializeMarkdown'; export { default as deserializeMarkdown } from './deserializeMarkdown';
export { default as flattenListItemParagraphs } from './flattenListItemParagraphs'; export { default as flattenListItemParagraphs } from './flattenListItemParagraphs';
export * from './toSlatePlugin';
export { default as toSlatePlugin } from './toSlatePlugin'; export { default as toSlatePlugin } from './toSlatePlugin';

View File

@ -0,0 +1,98 @@
/* eslint-disable no-case-declarations */
import { isEmpty, isNotEmpty } from '@staticcms/core/lib/util/string.util';
import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { BaseMdastNode, MdastNode } from './ast-types';
function cleanRegex(str: string) {
return str
.replace('[', '\\[')
.replace(']', '\\]')
.replace('(', '\\(')
.replace(')', '\\)')
.replace('|', '\\|');
}
function createShortcodeRegex(name: string, config: ShortcodeConfig) {
return `${cleanRegex(config.openTag)}(${name})${cleanRegex(
config.separator,
)}?([\\w\\W]*?)${cleanRegex(config.closeTag)}`;
}
export function processShortcodeConfigToSlate(
name: string,
config: ShortcodeConfig,
nodes: BaseMdastNode[],
) {
const output: MdastNode[] = [];
for (const node of nodes) {
if (node.value) {
const regex = new RegExp(`([\\w\\W]*?)${createShortcodeRegex(name, config)}([\\w\\W]*)`, 'g');
let matches: RegExpExecArray | null;
let rest = node.value;
while (isNotEmpty(rest) && (matches = regex.exec(rest)) !== null && matches.length === 5) {
const args = matches[3].trim();
if (isNotEmpty(matches[1])) {
output.push({
type: 'text',
value: matches[1],
});
}
output.push({
type: 'shortcode',
shortcode: name,
args: isEmpty(args) ? [] : args.split(config.separator),
children: [{ text: '' }],
});
rest = matches[4];
regex.lastIndex = 0;
}
if (isNotEmpty(rest)) {
output.push({
type: 'text',
value: rest,
});
}
continue;
}
output.push(node);
}
return output;
}
export function processShortcodeConfigToMdx(
configs: Record<string, ShortcodeConfig>,
markdown: string,
) {
if (isEmpty(markdown)) {
return '';
}
let output = markdown;
for (const name in configs) {
const config = configs[name];
const regex = new RegExp(createShortcodeRegex(name, config), 'g');
let matches: RegExpExecArray | null;
while ((matches = regex.exec(markdown)) !== null && matches.length === 3) {
const args = isEmpty(matches[2]) ? [] : matches[2]?.split(config.separator);
const argsOutput = args?.length > 0 ? `'${args.join("', '")}'` : '';
output = output.replace(
matches[0],
`<Shortcode shortcode="${matches[1]}" args={[${argsOutput}]} />`,
);
}
}
return output;
}

View File

@ -1,14 +1,21 @@
import transform from './deserializeMarkdown'; import transform from './deserializeMarkdown';
import type { ShortcodeConfig } from '@staticcms/core/interface';
import type { Plugin } from 'unified'; import type { Plugin } from 'unified';
import type { MdastNode } from './ast-types'; import type { MdastNode } from './ast-types';
const toSlatePlugin: Plugin = function () { export interface ToSlatePluginOptions {
const compiler = (node: { children: Array<MdastNode> }) => { shortcodeConfigs: Record<string, ShortcodeConfig>;
return node.children.map(c => transform(c, {})); useMdx: boolean;
}
const toSlatePlugin = ({ shortcodeConfigs, useMdx }: ToSlatePluginOptions): Plugin =>
function () {
const compiler = (node: { children: Array<MdastNode> }) => {
return node.children.map((c, index) => transform(c, { shortcodeConfigs, useMdx, index }));
};
this.Compiler = compiler;
}; };
this.Compiler = compiler;
};
export default toSlatePlugin; export default toSlatePlugin;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,127 @@
import { styled } from '@mui/material/styles';
import React, { useCallback, useMemo, useState } from 'react';
import FieldLabel from '@staticcms/core/components/UI/FieldLabel';
import Outline from '@staticcms/core/components/UI/Outline';
import useDebounce from '../../lib/hooks/useDebounce';
import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
import PlateEditor from './plate/PlateEditor';
import serializeMarkdown from './plate/serialization/serializeMarkdown';
import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react';
import type { MdValue } from './plate/plateTypes';
const StyledEditorWrapper = styled('div')`
position: relative;
width: 100%;
.toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor {
width: 100%;
}
.toastui-editor-main .toastui-editor-md-splitter {
display: none;
}
.toastui-editor-md-preview {
display: none;
}
.toastui-editor-defaultUI {
border: none;
}
`;
export interface WithMarkdownControlProps {
useMdx: boolean;
}
const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
const MarkdownControl: FC<WidgetControlProps<string, MarkdownField>> = controlProps => {
const { label, value, onChange, hasErrors, collection, entry, field } = controlProps;
const [internalValue, setInternalValue] = useState(value ?? '');
const [hasFocus, setHasFocus] = useState(false);
const debouncedFocus = useDebounce(hasFocus, 150);
const handleOnFocus = useCallback(() => {
setHasFocus(true);
}, []);
const handleOnBlur = useCallback(() => {
setHasFocus(false);
}, []);
const handleOnChange = useCallback(
(slateValue: MdValue) => {
const newValue = serializeMarkdown(slateValue, { useMdx });
if (newValue !== internalValue) {
setInternalValue(newValue);
onChange(newValue);
}
},
[internalValue, onChange],
);
const handleLabelClick = useCallback(() => {
// editorRef.current?.getInstance().focus();
}, []);
const [slateValue, loaded] = useMarkdownToSlate(internalValue, { useMdx });
return useMemo(
() => (
<StyledEditorWrapper key="markdown-control-wrapper">
<FieldLabel
key="markdown-control-label"
isActive={hasFocus}
hasErrors={hasErrors}
onClick={handleLabelClick}
>
{label}
</FieldLabel>
{loaded ? (
<PlateEditor
initialValue={slateValue}
collection={collection}
entry={entry}
field={field}
useMdx={useMdx}
controlProps={controlProps}
onChange={handleOnChange}
onFocus={handleOnFocus}
onBlur={handleOnBlur}
/>
) : null}
<Outline
key="markdown-control-outline"
hasLabel
hasError={hasErrors}
active={hasFocus || debouncedFocus}
/>
</StyledEditorWrapper>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
collection,
controlProps,
debouncedFocus,
field,
handleLabelClick,
handleOnBlur,
handleOnChange,
handleOnFocus,
hasErrors,
hasFocus,
label,
loaded,
slateValue,
],
);
};
return MarkdownControl;
};
export default withMarkdownControl;

View File

@ -0,0 +1,22 @@
import withMarkdownControl from '../markdown/withMarkdownControl';
import previewComponent from '../markdown/MarkdownPreview';
import schema from '../markdown/schema';
import type { MarkdownField, WidgetParam } from '@staticcms/core/interface';
const controlComponent = withMarkdownControl({ useMdx: true });
const MdxWidget = (): WidgetParam<string, MarkdownField> => {
return {
name: 'mdx',
controlComponent,
previewComponent,
options: {
schema,
},
};
};
export { controlComponent as MdxControl, previewComponent as MdxPreview, schema as MdxSchema };
export default MdxWidget;

View File

@ -10,5 +10,8 @@ if (typeof window === 'undefined') {
removeItem: jest.fn(), removeItem: jest.fn(),
getItem: jest.fn(), getItem: jest.fn(),
}, },
navigator: {
platform: 'Win',
},
}; };
} }

View File

@ -4,8 +4,8 @@
"declarationDir": "dist", "declarationDir": "dist",
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"jsx": "react", "jsx": "react",
"target": "esnext", "target": "ES2020",
"module": "esnext", "module": "ES2020",
"moduleResolution": "node", "moduleResolution": "node",
"esModuleInterop": true, "esModuleInterop": true,
"preserveSymlinks": true, "preserveSymlinks": true,
@ -17,7 +17,7 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"baseUrl": "./", "baseUrl": "./",
"lib": ["DOM", "es6", "ES2015"], "lib": ["DOM", "es6", "ES2015", "ES2020"],
"paths": { "paths": {
"@staticcms/boolean": ["./src/widgets/boolean"], "@staticcms/boolean": ["./src/widgets/boolean"],
"@staticcms/boolean/*": ["./src/widgets/boolean/*"], "@staticcms/boolean/*": ["./src/widgets/boolean/*"],