feat: add raw markdown editor (#866)
This commit is contained in:
parent
dbf007a586
commit
f37952a84a
@ -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
|
||||||
|
@ -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]
|
||||||
|
@ -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 {
|
||||||
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
@ -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;
|
||||||
|
@ -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: '' }] }],
|
||||||
|
@ -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: {
|
||||||
|
@ -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,24 +62,40 @@ 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 ? (
|
() =>
|
||||||
|
loaded ? (
|
||||||
<PlateEditor
|
<PlateEditor
|
||||||
|
key="plate-editor"
|
||||||
initialValue={slateValue}
|
initialValue={slateValue}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
@ -85,9 +106,7 @@ const withMarkdownControl = ({ useMdx }: WithMarkdownControlProps) => {
|
|||||||
onFocus={handleOnFocus}
|
onFocus={handleOnFocus}
|
||||||
onBlur={handleOnBlur}
|
onBlur={handleOnBlur}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null,
|
||||||
</Field>
|
|
||||||
),
|
|
||||||
// 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;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user