Feature/toast UI editor (#45)

This commit is contained in:
Daniel Lautzenheiser 2022-10-20 18:41:46 -04:00 committed by GitHub
parent e60e1fa755
commit 3674ee5bd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 205 additions and 101 deletions

View File

@ -25,7 +25,7 @@ module.exports = {
'react/prop-types': [0], 'react/prop-types': [0],
'react/require-default-props': 0, 'react/require-default-props': 0,
'import/no-named-as-default': 0, 'import/no-named-as-default': 0,
"react/react-in-jsx-scope": "off", 'react/react-in-jsx-scope': 'off',
'import/order': [ 'import/order': [
'error', 'error',
{ {
@ -89,7 +89,7 @@ module.exports = {
}, },
}, },
rules: { rules: {
"react/react-in-jsx-scope": "off", 'react/react-in-jsx-scope': 'off',
'react/prop-types': [0], 'react/prop-types': [0],
'react/require-default-props': 0, 'react/require-default-props': 0,
'no-duplicate-imports': [0], // handled by @typescript-eslint 'no-duplicate-imports': [0], // handled by @typescript-eslint

View File

@ -1,6 +1,6 @@
{ {
"name": "@staticcms/core", "name": "@staticcms/core",
"version": "0.3.7", "version": "1.0.0-alpha1",
"license": "MIT", "license": "MIT",
"description": "Static CMS core application.", "description": "Static CMS core application.",
"repository": "https://github.com/StaticJsCMS/static-cms", "repository": "https://github.com/StaticJsCMS/static-cms",
@ -93,7 +93,6 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"mdast-util-definitions": "1.2.5", "mdast-util-definitions": "1.2.5",
"mdast-util-to-string": "1.1.0", "mdast-util-to-string": "1.1.0",
"mime-types": "^2.1.35",
"minimatch": "3.0.4", "minimatch": "3.0.4",
"moment": "2.29.4", "moment": "2.29.4",
"node-polyglot": "2.4.2", "node-polyglot": "2.4.2",
@ -170,7 +169,6 @@
"@types/js-yaml": "4.0.5", "@types/js-yaml": "4.0.5",
"@types/jwt-decode": "2.2.1", "@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.185", "@types/lodash": "4.14.185",
"@types/mime-types": "^2.1.1",
"@types/minimatch": "5.1.2", "@types/minimatch": "5.1.2",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"@types/react": "18.0.21", "@types/react": "18.0.21",

View File

@ -10,7 +10,7 @@ import type { MouseEvent } from 'react';
import type { import type {
AuthenticationPageProps, AuthenticationPageProps,
AuthenticatorConfig, AuthenticatorConfig,
TranslatedProps TranslatedProps,
} from '../../interface'; } from '../../interface';
const LoginButtonIcon = styled(Icon)` const LoginButtonIcon = styled(Icon)`

View File

@ -195,12 +195,14 @@ const Editor = ({
useEffect(() => { useEffect(() => {
if (hasChanged && entryDraft.entry) { if (hasChanged && entryDraft.entry) {
createBackup(entryDraft.entry, collection); createBackup(entryDraft.entry, collection);
} else if (localBackup) {
deleteBackup();
} }
return () => { return () => {
createBackup.flush(); createBackup.flush();
}; };
}, [collection, createBackup, entryDraft.entry, hasChanged]); }, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [preSlug, setPrevSlug] = useState<string | undefined | null>(null); const [preSlug, setPrevSlug] = useState<string | undefined | null>(null);

View File

@ -5,13 +5,12 @@ import { translate } from 'react-polyglot';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { import {
addDraftEntryMediaFile as addDraftEntryMediaFileAction,
changeDraftField as changeDraftFieldAction, changeDraftField as changeDraftFieldAction,
changeDraftFieldValidation as changeDraftFieldValidationAction, changeDraftFieldValidation as changeDraftFieldValidationAction,
clearFieldErrors as clearFieldErrorsAction, clearFieldErrors as clearFieldErrorsAction,
tryLoadEntry, tryLoadEntry,
} from '../../../actions/entries'; } from '../../../actions/entries';
import { addAsset as addAssetAction, getAsset as getAssetAction } from '../../../actions/media'; import { getAsset as getAssetAction } from '../../../actions/media';
import { import {
clearMediaControl as clearMediaControlAction, clearMediaControl as clearMediaControlAction,
openMediaLibrary as openMediaLibraryAction, openMediaLibrary as openMediaLibraryAction,
@ -157,8 +156,6 @@ const EditorControl = ({
locale, locale,
mediaPaths, mediaPaths,
changeDraftFieldValidation, changeDraftFieldValidation,
addAsset,
addDraftEntryMediaFile,
openMediaLibrary, openMediaLibrary,
parentPath, parentPath,
query, query,
@ -237,8 +234,6 @@ const EditorControl = ({
mediaPaths, mediaPaths,
onChange: handleChangeDraftField, onChange: handleChangeDraftField,
clearMediaControl, clearMediaControl,
addAsset,
addDraftEntryMediaFile,
openMediaLibrary, openMediaLibrary,
removeInsertedMedia, removeInsertedMedia,
removeMediaControl, removeMediaControl,
@ -318,8 +313,6 @@ function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) {
const mapDispatchToProps = { const mapDispatchToProps = {
changeDraftField: changeDraftFieldAction, changeDraftField: changeDraftFieldAction,
changeDraftFieldValidation: changeDraftFieldValidationAction, changeDraftFieldValidation: changeDraftFieldValidationAction,
addAsset: addAssetAction,
addDraftEntryMediaFile: addDraftEntryMediaFileAction,
openMediaLibrary: openMediaLibraryAction, openMediaLibrary: openMediaLibraryAction,
clearMediaControl: clearMediaControlAction, clearMediaControl: clearMediaControlAction,
removeMediaControl: removeMediaControlAction, removeMediaControl: removeMediaControlAction,

View File

@ -117,7 +117,9 @@ const ListItemTopBar = ({
/> />
</IconButton> </IconButton>
) : null} ) : null}
<StyledTitle key="title" onClick={onCollapseToggle}>{title}</StyledTitle> <StyledTitle key="title" onClick={onCollapseToggle}>
{title}
</StyledTitle>
{dragHandleHOC ? <DragHandle dragHandleHOC={dragHandleHOC} /> : null} {dragHandleHOC ? <DragHandle dragHandleHOC={dragHandleHOC} /> : null}
{onRemove ? ( {onRemove ? (
<TopBarButton onClick={onRemove}> <TopBarButton onClick={onRemove}>

2
src/constants/files.ts Normal file
View File

@ -0,0 +1,2 @@
export const IMAGE_EXTENSION_REGEX =
/(\.apng|\.avif|\.gif|\.jpg|\.jpeg|\.jfif|\.pjpeg|\.pjp|\.png|\.svg|\.webp)$/g;

View File

@ -1,20 +1,44 @@
import { useCallback } from 'react'; import { useEffect, useMemo } from 'react';
import type { EditorProps } from '@toast-ui/react-editor'; import type { ToolbarItemOptions } from '@toast-ui/editor/types/ui';
import type { PluginContext } from '@toast-ui/editor/types/editor'; export interface ImagePluginProps {
import type { Field } from '../interface'; openMediaLibrary: (forImages: boolean) => void;
export interface ShortCodePluginProps {
fields: Field;
} }
const useShortCodePlugin = (_props: ShortCodePluginProps) => { const PREFIX = 'toastui-editor-';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const plugin: Required<EditorProps>['plugins'][number] = useCallback((_context: PluginContext, _options?: any) => { const useImagePlugin = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOptions => {
return null; const toolbarButton = useMemo(() => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'image toastui-editor-toolbar-icons';
btn.ariaLabel = 'Insert image';
btn.setAttribute('style', 'margin: 0;');
return btn;
}, []); }, []);
return plugin; useEffect(() => {
const handler = () => {
openMediaLibrary(true);
};
toolbarButton.addEventListener('click', handler);
return () => {
toolbarButton.removeEventListener('click', handler);
};
}, [openMediaLibrary, toolbarButton]);
const toolbarItem: ToolbarItemOptions = useMemo(
() => ({
name: 'cmsimage',
className: `${PREFIX}toolbar-icons color`,
el: toolbarButton,
}),
[toolbarButton],
);
return toolbarItem;
}; };
export default useShortCodePlugin; export default useImagePlugin;

View File

@ -246,8 +246,6 @@ export interface WidgetControlProps<T, F extends Field = Field> {
locale: string | undefined; locale: string | undefined;
mediaPaths: Record<string, string | string[]>; mediaPaths: Record<string, string | string[]>;
onChange: (value: T | null | undefined) => void; onChange: (value: T | null | undefined) => void;
addAsset: EditorControlProps['addAsset'];
addDraftEntryMediaFile: EditorControlProps['addDraftEntryMediaFile'];
clearMediaControl: EditorControlProps['clearMediaControl']; clearMediaControl: EditorControlProps['clearMediaControl'];
openMediaLibrary: EditorControlProps['openMediaLibrary']; openMediaLibrary: EditorControlProps['openMediaLibrary'];
removeInsertedMedia: EditorControlProps['removeInsertedMedia']; removeInsertedMedia: EditorControlProps['removeInsertedMedia'];

View File

@ -0,0 +1,12 @@
export async function doesUrlFileExist(url: string): Promise<{ type: string; exists: boolean }> {
const cleanUrl = url.replace(/^blob:/g, '');
const baseUrl = `${window.location.protocol}//${window.location.host}/`;
if (!cleanUrl.startsWith('/') && !cleanUrl.startsWith(baseUrl)) {
return { type: 'Unknown', exists: true };
}
const response = await fetch(cleanUrl, { method: 'HEAD' });
return { type: response.headers.get('Content-Type') ?? 'text', exists: response.ok };
}

View File

@ -8,7 +8,7 @@ import type {
FieldValidationMethod, FieldValidationMethod,
FieldValidationMethodProps, FieldValidationMethodProps,
ValueOrNestedValue, ValueOrNestedValue,
Widget Widget,
} from '../../interface'; } from '../../interface';
export function isEmpty(value: ValueOrNestedValue) { export function isEmpty(value: ValueOrNestedValue) {

View File

@ -9,9 +9,11 @@ type MessageType = 'error' | 'warning' | 'info' | 'success';
export interface SnackbarMessage { export interface SnackbarMessage {
id: string; id: string;
type: MessageType; type: MessageType;
message: string | { message:
key: string; | string
} & Record<string, unknown>; | ({
key: string;
} & Record<string, unknown>);
} }
// Define a type for the slice state // Define a type for the slice state

View File

@ -1,20 +1,24 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { Editor } from '@toast-ui/react-editor'; import { Editor } from '@toast-ui/react-editor';
import mime from 'mime-types'; import isEmpty from 'lodash/isEmpty';
import React, { useCallback, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import uuid from 'uuid'; import uuid from 'uuid';
import FieldLabel from '../../components/UI/FieldLabel'; import FieldLabel from '../../components/UI/FieldLabel';
import Outline from '../../components/UI/Outline'; import Outline from '../../components/UI/Outline';
import { sanitizeSlug } from '../../lib/urlHelper'; import { IMAGE_EXTENSION_REGEX } from '../../constants/files';
import { selectMediaFilePath } from '../../lib/util/media.util'; import useImagePlugin from '../../editor-components/editorPlugin';
import { createAssetProxy } from '../../valueObjects/AssetProxy'; import { doesUrlFileExist } from '../../lib/util/fetch.util';
import { isNotNullish } from '../../lib/util/null.util';
import { isNotEmpty } from '../../lib/util/string.util';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { MarkdownField, WidgetControlProps } from '../../interface'; import type { MarkdownField, MediaLibrary, WidgetControlProps } from '../../interface';
import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/toastui-editor.css';
const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/;
const StyledEditorWrapper = styled('div')` const StyledEditorWrapper = styled('div')`
position: relative; position: relative;
width: 100%; width: 100%;
@ -42,11 +46,9 @@ const MarkdownControl = ({
onChange, onChange,
hasErrors, hasErrors,
field, field,
addAsset, openMediaLibrary,
addDraftEntryMediaFile, mediaPaths,
config, getAsset,
collection,
entry,
}: WidgetControlProps<string, MarkdownField>) => { }: WidgetControlProps<string, MarkdownField>) => {
const [internalValue, setInternalValue] = useState(value ?? ''); const [internalValue, setInternalValue] = useState(value ?? '');
const editorRef = useMemo(() => React.createRef(), []) as RefObject<Editor>; const editorRef = useMemo(() => React.createRef(), []) as RefObject<Editor>;
@ -70,43 +72,88 @@ const MarkdownControl = ({
editorRef.current?.getInstance().focus(); editorRef.current?.getInstance().focus();
}, [editorRef]); }, [editorRef]);
const imageUpload = useCallback( const controlID: string = useMemo(() => uuid(), []);
(blob: Blob | File, callback: (url: string, text?: string) => void) => { const mediaLibraryFieldOptions: MediaLibrary = useMemo(
let file: File; () => field.media_library ?? {},
if (blob instanceof Blob) { [field.media_library],
blob.type;
file = new File([blob], `${uuid()}.${mime.extension(blob.type)}`);
} else {
file = blob;
}
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
const path = selectMediaFilePath(config, collection, entry, fileName, field);
const blobUrl = URL.createObjectURL(file);
addAsset(
createAssetProxy({
url: blobUrl,
file,
path,
field,
}),
);
addDraftEntryMediaFile({
name: file.name,
id: file.name,
size: file.size,
displayURL: blobUrl,
path,
draft: true,
url: blobUrl,
file,
field,
});
console.log(blob);
callback(path);
handleOnChange();
},
[addAsset, addDraftEntryMediaFile, collection, config, entry, field, handleOnChange],
); );
const handleOpenMedialLibrary = useCallback(
(forImage: boolean) => {
openMediaLibrary({
controlID,
forImage,
privateUpload: false,
allowMultiple: false,
field,
config: 'config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined,
});
},
[controlID, field, mediaLibraryFieldOptions, openMediaLibrary],
);
const imageToolbarButton = useImagePlugin({
openMediaLibrary: handleOpenMedialLibrary,
});
const getMedia = useCallback(
async (path: string) => {
const { type, exists } = await doesUrlFileExist(path);
if (!exists) {
const asset = getAsset(path, field);
if (isNotNullish(asset)) {
return {
type: IMAGE_EXTENSION_REGEX.test(path) ? 'image' : 'file',
exists: false,
url: asset.toString(),
};
}
}
return { url: path, type, exists };
},
[field, getAsset],
);
const mediaPath = mediaPaths[controlID];
useEffect(() => {
if (isEmpty(mediaPath) || Array.isArray(mediaPath)) {
return;
}
const addMedia = async () => {
const { type } = await getMedia(mediaPath);
let content: string | undefined;
const name = mediaPath.split('/').pop();
if (type.startsWith('image')) {
content = `![${name}](${mediaPath})`;
} else {
content = `[${name}](${mediaPath})`;
}
if (isNotEmpty(content)) {
const editorInstance = editorRef.current?.getInstance();
if (!editorInstance) {
return;
}
editorInstance.focus();
const isOnMarkdown = editorInstance.isMarkdownMode();
if (!isOnMarkdown) {
editorInstance.changeMode('markdown');
}
editorInstance.insertText(content);
if (!isOnMarkdown) {
editorInstance.changeMode('wysiwyg');
}
setTimeout(() => {
handleOnChange();
});
}
};
addMedia();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [field, mediaPath]);
return ( return (
<StyledEditorWrapper key="markdown-control-wrapper"> <StyledEditorWrapper key="markdown-control-wrapper">
@ -130,16 +177,42 @@ const MarkdownControl = ({
['heading', 'bold', 'italic', 'strike'], ['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'], ['hr', 'quote'],
['ul', 'ol', 'task', 'indent', 'outdent'], ['ul', 'ol', 'task', 'indent', 'outdent'],
['table', 'image', 'link'], ['table', imageToolbarButton, 'link'],
['code', 'codeblock'], ['code', 'codeblock'],
]} ]}
ref={editorRef} ref={editorRef}
onFocus={handleOnFocus} onFocus={handleOnFocus}
onBlur={handleOnBlur} onBlur={handleOnBlur}
hooks={{
addImageBlobHook: imageUpload,
}}
autofocus={false} autofocus={false}
widgetRules={[
{
rule: imageFilePattern,
toDOM(text) {
const rule = imageFilePattern;
const matched = text.match(rule);
if (matched) {
if (matched?.length === 4) {
// Image
const img = document.createElement('img');
img.setAttribute('src', getAsset(matched[3], field).url);
img.setAttribute('style', 'width: 100%;');
img.innerHTML = 'test';
return img;
} else {
// File
const a = document.createElement('a');
a.setAttribute('target', '_blank');
a.setAttribute('href', matched[2]);
a.innerHTML = matched[1];
return a;
}
}
return document.createElement('div');
},
},
]}
/> />
<Outline key="markdown-control-outline" hasLabel hasError={hasErrors} /> <Outline key="markdown-control-outline" hasLabel hasError={hasErrors} />
</StyledEditorWrapper> </StyledEditorWrapper>

View File

@ -39,20 +39,15 @@ const SelectControl = ({
const isEmpty = const isEmpty =
isMultiple && Array.isArray(selectedOption) ? !selectedOption?.length : !selectedOption; isMultiple && Array.isArray(selectedOption) ? !selectedOption?.length : !selectedOption;
console.log('[value]', selectedOption, field.required, isEmpty, isMultiple);
if (field.required && isEmpty && isMultiple) { if (field.required && isEmpty && isMultiple) {
setInternalValue([]); setInternalValue([]);
onChange([]); onChange([]);
console.log('emtpy array!');
} else if (isEmpty) { } else if (isEmpty) {
setInternalValue(''); setInternalValue('');
onChange(''); onChange('');
console.log('emtpy string!');
} else if (typeof selectedOption === 'string' || isMultiple) { } else if (typeof selectedOption === 'string' || isMultiple) {
setInternalValue(selectedOption); setInternalValue(selectedOption);
onChange(selectedOption); onChange(selectedOption);
console.log('valid value!', selectedOption);
} }
}, },
[field, onChange], [field, onChange],
@ -85,8 +80,8 @@ const SelectControl = ({
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selectValues.map(selectValue => { {selectValues.map(selectValue => {
const label = optionsByValue[selectValue]?.label ?? selectValue; const label = optionsByValue[selectValue]?.label ?? selectValue;
return <Chip key={selectValue} label={label} /> return <Chip key={selectValue} label={label} />;
})} })}
</Box> </Box>
) : ( ) : (
selectValues selectValues

View File

@ -4,7 +4,12 @@ import React, { useCallback, useState } from 'react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import type { StringOrTextField, WidgetControlProps } from '../../interface'; import type { StringOrTextField, WidgetControlProps } from '../../interface';
const TextControl = ({ label, value, onChange, hasErrors }: WidgetControlProps<string, StringOrTextField>) => { const TextControl = ({
label,
value,
onChange,
hasErrors,
}: WidgetControlProps<string, StringOrTextField>) => {
const [internalValue, setInternalValue] = useState(value ?? ''); const [internalValue, setInternalValue] = useState(value ?? '');
const handleChange = useCallback( const handleChange = useCallback(

View File

@ -7,7 +7,10 @@ class GitHubStarButton extends PureComponent {
href="https://github.com/StaticJsCMS/static-cms" href="https://github.com/StaticJsCMS/static-cms"
aria-label="Star StaticJsCMS/static-cms on GitHub" aria-label="Star StaticJsCMS/static-cms on GitHub"
> >
<img alt="Star StaticJsCMS/static-cms on GitHub" src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social" /> <img
alt="Star StaticJsCMS/static-cms on GitHub"
src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social"
/>
</a> </a>
); );
} }

View File

@ -2250,11 +2250,6 @@
dependencies: dependencies:
"@types/unist" "*" "@types/unist" "*"
"@types/mime-types@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
"@types/mime@*": "@types/mime@*":
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
@ -7193,7 +7188,7 @@ mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34:
version "2.1.35" version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==