feat: add raw markdown editor (#866)

This commit is contained in:
Daniel Lautzenheiser 2023-09-06 16:59:27 -04:00 committed by GitHub
parent dbf007a586
commit f37952a84a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 118 additions and 32 deletions

View File

@ -694,6 +694,10 @@ collections:
label: Required With Default label: Required With Default
widget: markdown widget: markdown
default: Default **markdown** value default: Default **markdown** value
- name: raw
label: Raw Editor
widget: markdown
show_raw: true
- name: pattern - name: pattern
label: Pattern Validation label: Pattern Validation
widget: markdown widget: markdown

View File

@ -6,6 +6,8 @@ import type { ChangeEventHandler, RefObject } from 'react';
export interface TextAreaProps { export interface TextAreaProps {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
placeholder?: string;
className?: string;
'data-testid'?: string; 'data-testid'?: string;
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
} }
@ -18,7 +20,7 @@ function getHeight(rawHeight: string): number {
} }
const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>( const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
({ value, disabled, 'data-testid': dataTestId, onChange }, ref) => { ({ value, disabled, placeholder, className, 'data-testid': dataTestId, onChange }, ref) => {
const [lastAutogrowHeight, setLastAutogrowHeight] = useState(MIN_TEXT_AREA_HEIGHT); const [lastAutogrowHeight, setLastAutogrowHeight] = useState(MIN_TEXT_AREA_HEIGHT);
const autoGrow = useCallback(() => { const autoGrow = useCallback(() => {
@ -63,10 +65,12 @@ const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
className: ` className: `
flex flex
w-full w-full
${className}
`, `,
}, },
input: { input: {
ref, ref,
placeholder,
className: ` className: `
w-full w-full
min-h-[80px] min-h-[80px]

View File

@ -769,6 +769,7 @@ export interface MarkdownField extends MediaField {
widget: 'markdown'; widget: 'markdown';
toolbar_buttons?: MarkdownFieldToolbarButtons; toolbar_buttons?: MarkdownFieldToolbarButtons;
default?: string; default?: string;
show_raw?: string;
} }
export interface NumberField extends BaseField { export interface NumberField extends BaseField {

View File

@ -144,6 +144,7 @@ const en: LocalePhrasesRoot = {
addComponent: 'Add Component', addComponent: 'Add Component',
richText: 'Rich Text', richText: 'Rich Text',
markdown: 'Markdown', markdown: 'Markdown',
type: 'Type...',
}, },
image: { image: {
choose: 'Choose an image', choose: 'Choose an image',

View File

@ -53,6 +53,7 @@ import { StyledLeaf } from '@udecode/plate-styled-components';
import React, { useMemo, useRef } 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 { useTranslate } from 'react-polyglot';
import useUUID from '@staticcms/core/lib/hooks/useUUID'; import useUUID from '@staticcms/core/lib/hooks/useUUID';
import { CodeBlockElement, withShortcodeElement } from './components'; import { CodeBlockElement, withShortcodeElement } from './components';
@ -104,6 +105,7 @@ import type {
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate'; import type { AnyObject, AutoformatPlugin, PlatePlugin } from '@udecode/plate';
import type { FC } from 'react'; import type { FC } from 'react';
import type { t as T } from 'react-polyglot';
import type { MdEditor, MdValue } from './plateTypes'; import type { MdEditor, MdValue } from './plateTypes';
export interface PlateEditorProps { export interface PlateEditorProps {
@ -129,6 +131,8 @@ const PlateEditor: FC<PlateEditorProps> = ({
onFocus, onFocus,
onBlur, onBlur,
}) => { }) => {
const t = useTranslate() as T;
const editorContainerRef = useRef<HTMLDivElement | null>(null); const editorContainerRef = useRef<HTMLDivElement | null>(null);
const innerEditorContainerRef = useRef<HTMLDivElement | null>(null); const innerEditorContainerRef = useRef<HTMLDivElement | null>(null);
@ -278,6 +282,7 @@ const PlateEditor: FC<PlateEditorProps> = ({
id={id} id={id}
editableProps={{ editableProps={{
...editableProps, ...editableProps,
placeholder: t('editor.editorWidgets.markdown.type'),
onFocus, onFocus,
onBlur, onBlur,
className: '!outline-none', className: '!outline-none',

View File

@ -5,7 +5,6 @@ const editableProps: TEditableProps<MdValue> = {
spellCheck: false, spellCheck: false,
autoFocus: false, autoFocus: false,
readOnly: false, readOnly: false,
placeholder: 'Type…',
}; };
export default editableProps; export default editableProps;

View File

@ -14,6 +14,7 @@ import type { MdValue } from '../plateTypes';
export interface UseMarkdownToSlateOptions { export interface UseMarkdownToSlateOptions {
shortcodeConfigs?: Record<string, ShortcodeConfig>; shortcodeConfigs?: Record<string, ShortcodeConfig>;
useMdx: boolean; useMdx: boolean;
unload?: boolean;
} }
export const markdownToSlate = async ( export const markdownToSlate = async (
@ -45,12 +46,17 @@ const useMarkdownToSlate = (
const [slateValue, setSlateValue] = useState<MdValue>([]); const [slateValue, setSlateValue] = useState<MdValue>([]);
useEffect(() => { useEffect(() => {
if (loaded && !options.unload) {
return;
}
markdownToSlate(markdownValue, options).then(value => { markdownToSlate(markdownValue, options).then(value => {
setSlateValue(value); setSlateValue(value);
setLoaded(true); setLoaded(true);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [markdownValue]);
return [ return [
slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }], slateValue.length > 0 ? slateValue : [{ type: ELEMENT_PARAGRAPH, children: [{ text: '' }] }],

View File

@ -106,6 +106,7 @@ export default {
public_folder: { type: 'string' }, public_folder: { type: 'string' },
choose_url: { type: 'boolean' }, choose_url: { type: 'boolean' },
multiple: { type: 'boolean' }, multiple: { type: 'boolean' },
show_raw: { type: 'boolean' },
toolbar_buttons: { toolbar_buttons: {
type: 'object', type: 'object',
properties: { properties: {

View File

@ -1,13 +1,15 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import Field from '@staticcms/core/components/common/field/Field'; import Field from '@staticcms/core/components/common/field/Field';
import TextArea from '@staticcms/core/components/common/text-field/TextArea';
import useDebounce from '../../lib/hooks/useDebounce'; import useDebounce from '../../lib/hooks/useDebounce';
import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
import PlateEditor from './plate/PlateEditor'; import PlateEditor from './plate/PlateEditor';
import useMarkdownToSlate from './plate/hooks/useMarkdownToSlate';
import serializeMarkdown from './plate/serialization/serializeMarkdown'; import serializeMarkdown from './plate/serialization/serializeMarkdown';
import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface'; import type { MarkdownField, WidgetControlProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { ChangeEvent, FC } from 'react';
import type { MdValue } from './plate/plateTypes'; import type { MdValue } from './plate/plateTypes';
export interface WithMarkdownControlProps { export interface WithMarkdownControlProps {
@ -28,6 +30,7 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
errors, errors,
forSingleList, forSingleList,
disabled, disabled,
t,
} = controlProps; } = controlProps;
const [internalRawValue, setInternalValue] = useState(value ?? ''); const [internalRawValue, setInternalValue] = useState(value ?? '');
@ -38,6 +41,8 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
const [hasFocus, setHasFocus] = useState(false); const [hasFocus, setHasFocus] = useState(false);
const debouncedFocus = useDebounce(hasFocus, 150); const debouncedFocus = useDebounce(hasFocus, 150);
const [showRaw, setShowRaw] = useState(false);
const handleOnFocus = useCallback(() => { const handleOnFocus = useCallback(() => {
setHasFocus(true); setHasFocus(true);
}, []); }, []);
@ -57,37 +62,51 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
[internalValue, onChange], [internalValue, onChange],
); );
const handleRawOnChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const rawValue = event.target.value;
if (rawValue !== internalValue) {
setInternalValue(rawValue);
onChange(rawValue);
}
},
[internalValue, onChange],
);
const handleLabelClick = useCallback(() => { const handleLabelClick = useCallback(() => {
// editorRef.current?.getInstance().focus(); // editorRef.current?.getInstance().focus();
}, []); }, []);
const [slateValue, loaded] = useMarkdownToSlate(internalValue, { useMdx }); const handleShowRaw = useCallback(() => {
if (!field.show_raw) {
return;
}
return useMemo( setShowRaw(true);
() => ( }, [field.show_raw]);
<Field
label={label} const handleShowRich = useCallback(() => {
errors={errors} setShowRaw(false);
forSingleList={forSingleList} }, []);
hint={field.hint}
noHightlight const [slateValue, loaded] = useMarkdownToSlate(internalValue, { useMdx, unload: showRaw });
disabled={disabled}
> const richEditor = useMemo(
{loaded ? ( () =>
<PlateEditor loaded ? (
initialValue={slateValue} <PlateEditor
collection={collection} key="plate-editor"
entry={entry} initialValue={slateValue}
field={field} collection={collection}
useMdx={useMdx} entry={entry}
controlProps={controlProps} field={field}
onChange={handleOnChange} useMdx={useMdx}
onFocus={handleOnFocus} controlProps={controlProps}
onBlur={handleOnBlur} onChange={handleOnChange}
/> onFocus={handleOnFocus}
) : null} onBlur={handleOnBlur}
</Field> />
), ) : null,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
collection, collection,
@ -100,11 +119,57 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
handleOnFocus, handleOnFocus,
hasErrors, hasErrors,
hasFocus, hasFocus,
label,
loaded, loaded,
slateValue, slateValue,
showRaw,
], ],
); );
return (
<Field
label={label}
errors={errors}
forSingleList={forSingleList}
hint={field.hint}
noHightlight
disabled={disabled}
>
{showRaw ? (
<TextArea
key="raw-editor"
value={internalValue}
disabled={disabled}
onChange={handleRawOnChange}
placeholder={t('editor.editorWidgets.markdown.type')}
className="mt-2"
/>
) : (
richEditor
)}
{field.show_raw ? (
<div className="px-3 mt-2 flex gap-2">
<Button
data-testid="rich-editor"
size="small"
variant={!showRaw ? 'contained' : 'outlined'}
onClick={handleShowRich}
disabled={disabled}
>
{t('editor.editorWidgets.markdown.richText')}
</Button>
<Button
data-testid="rich-editor"
size="small"
variant={showRaw ? 'contained' : 'outlined'}
onClick={handleShowRaw}
disabled={disabled}
>
{t('editor.editorWidgets.markdown.markdown')}
</Button>
</div>
) : null}
</Field>
);
}; };
return MarkdownControl; return MarkdownControl;