From 3674ee5bd86bbdaa8d816142c0ffaed6085dbaae Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Thu, 20 Oct 2022 18:41:46 -0400 Subject: [PATCH] Feature/toast UI editor (#45) --- .eslintrc.js | 4 +- package.json | 4 +- src/backends/gitlab/AuthenticationPage.tsx | 2 +- src/components/Editor/Editor.tsx | 4 +- .../EditorControlPane/EditorControl.tsx | 9 +- src/components/UI/ListItemTopBar.tsx | 4 +- src/constants/files.ts | 2 + src/editor-components/editorPlugin.ts | 50 +++-- src/interface.ts | 2 - src/lib/util/fetch.util.ts | 12 ++ src/lib/util/validation.util.ts | 2 +- src/store/slices/snackbars.ts | 8 +- src/widgets/markdown/MarkdownControl.tsx | 175 +++++++++++++----- src/widgets/select/SelectControl.tsx | 9 +- src/widgets/text/TextControl.tsx | 7 +- website/src/components/github-button.js | 5 +- yarn.lock | 7 +- 17 files changed, 205 insertions(+), 101 deletions(-) create mode 100644 src/constants/files.ts create mode 100644 src/lib/util/fetch.util.ts diff --git a/.eslintrc.js b/.eslintrc.js index 37bacab7..e1e0cdbd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,7 +25,7 @@ module.exports = { 'react/prop-types': [0], 'react/require-default-props': 0, 'import/no-named-as-default': 0, - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', 'import/order': [ 'error', { @@ -89,7 +89,7 @@ module.exports = { }, }, rules: { - "react/react-in-jsx-scope": "off", + 'react/react-in-jsx-scope': 'off', 'react/prop-types': [0], 'react/require-default-props': 0, 'no-duplicate-imports': [0], // handled by @typescript-eslint diff --git a/package.json b/package.json index 47f7d688..02556fcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@staticcms/core", - "version": "0.3.7", + "version": "1.0.0-alpha1", "license": "MIT", "description": "Static CMS core application.", "repository": "https://github.com/StaticJsCMS/static-cms", @@ -93,7 +93,6 @@ "lodash": "4.17.21", "mdast-util-definitions": "1.2.5", "mdast-util-to-string": "1.1.0", - "mime-types": "^2.1.35", "minimatch": "3.0.4", "moment": "2.29.4", "node-polyglot": "2.4.2", @@ -170,7 +169,6 @@ "@types/js-yaml": "4.0.5", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.185", - "@types/mime-types": "^2.1.1", "@types/minimatch": "5.1.2", "@types/node-fetch": "2.6.2", "@types/react": "18.0.21", diff --git a/src/backends/gitlab/AuthenticationPage.tsx b/src/backends/gitlab/AuthenticationPage.tsx index 3b5a502f..3712600c 100644 --- a/src/backends/gitlab/AuthenticationPage.tsx +++ b/src/backends/gitlab/AuthenticationPage.tsx @@ -10,7 +10,7 @@ import type { MouseEvent } from 'react'; import type { AuthenticationPageProps, AuthenticatorConfig, - TranslatedProps + TranslatedProps, } from '../../interface'; const LoginButtonIcon = styled(Icon)` diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index a9518482..11fd85ca 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -195,12 +195,14 @@ const Editor = ({ useEffect(() => { if (hasChanged && entryDraft.entry) { createBackup(entryDraft.entry, collection); + } else if (localBackup) { + deleteBackup(); } return () => { createBackup.flush(); }; - }, [collection, createBackup, entryDraft.entry, hasChanged]); + }, [collection, createBackup, deleteBackup, entryDraft.entry, hasChanged, localBackup]); const [prevCollection, setPrevCollection] = useState(null); const [preSlug, setPrevSlug] = useState(null); diff --git a/src/components/Editor/EditorControlPane/EditorControl.tsx b/src/components/Editor/EditorControlPane/EditorControl.tsx index 55819b49..b215de37 100644 --- a/src/components/Editor/EditorControlPane/EditorControl.tsx +++ b/src/components/Editor/EditorControlPane/EditorControl.tsx @@ -5,13 +5,12 @@ import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; import { - addDraftEntryMediaFile as addDraftEntryMediaFileAction, changeDraftField as changeDraftFieldAction, changeDraftFieldValidation as changeDraftFieldValidationAction, clearFieldErrors as clearFieldErrorsAction, tryLoadEntry, } from '../../../actions/entries'; -import { addAsset as addAssetAction, getAsset as getAssetAction } from '../../../actions/media'; +import { getAsset as getAssetAction } from '../../../actions/media'; import { clearMediaControl as clearMediaControlAction, openMediaLibrary as openMediaLibraryAction, @@ -157,8 +156,6 @@ const EditorControl = ({ locale, mediaPaths, changeDraftFieldValidation, - addAsset, - addDraftEntryMediaFile, openMediaLibrary, parentPath, query, @@ -237,8 +234,6 @@ const EditorControl = ({ mediaPaths, onChange: handleChangeDraftField, clearMediaControl, - addAsset, - addDraftEntryMediaFile, openMediaLibrary, removeInsertedMedia, removeMediaControl, @@ -318,8 +313,6 @@ function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) { const mapDispatchToProps = { changeDraftField: changeDraftFieldAction, changeDraftFieldValidation: changeDraftFieldValidationAction, - addAsset: addAssetAction, - addDraftEntryMediaFile: addDraftEntryMediaFileAction, openMediaLibrary: openMediaLibraryAction, clearMediaControl: clearMediaControlAction, removeMediaControl: removeMediaControlAction, diff --git a/src/components/UI/ListItemTopBar.tsx b/src/components/UI/ListItemTopBar.tsx index 86a0421b..ee92eb2b 100644 --- a/src/components/UI/ListItemTopBar.tsx +++ b/src/components/UI/ListItemTopBar.tsx @@ -117,7 +117,9 @@ const ListItemTopBar = ({ /> ) : null} - {title} + + {title} + {dragHandleHOC ? : null} {onRemove ? ( diff --git a/src/constants/files.ts b/src/constants/files.ts new file mode 100644 index 00000000..1b349f97 --- /dev/null +++ b/src/constants/files.ts @@ -0,0 +1,2 @@ +export const IMAGE_EXTENSION_REGEX = + /(\.apng|\.avif|\.gif|\.jpg|\.jpeg|\.jfif|\.pjpeg|\.pjp|\.png|\.svg|\.webp)$/g; diff --git a/src/editor-components/editorPlugin.ts b/src/editor-components/editorPlugin.ts index f75e1ff6..c9c6913f 100644 --- a/src/editor-components/editorPlugin.ts +++ b/src/editor-components/editorPlugin.ts @@ -1,20 +1,44 @@ -import { useCallback } from 'react'; +import { useEffect, useMemo } from 'react'; -import type { EditorProps } from '@toast-ui/react-editor'; -import type { PluginContext } from '@toast-ui/editor/types/editor'; -import type { Field } from '../interface'; - -export interface ShortCodePluginProps { - fields: Field; +import type { ToolbarItemOptions } from '@toast-ui/editor/types/ui'; +export interface ImagePluginProps { + openMediaLibrary: (forImages: boolean) => void; } -const useShortCodePlugin = (_props: ShortCodePluginProps) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const plugin: Required['plugins'][number] = useCallback((_context: PluginContext, _options?: any) => { - return null; +const PREFIX = 'toastui-editor-'; + +const useImagePlugin = ({ openMediaLibrary }: ImagePluginProps): ToolbarItemOptions => { + 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; diff --git a/src/interface.ts b/src/interface.ts index 9a88bc32..58771d40 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -246,8 +246,6 @@ export interface WidgetControlProps { locale: string | undefined; mediaPaths: Record; onChange: (value: T | null | undefined) => void; - addAsset: EditorControlProps['addAsset']; - addDraftEntryMediaFile: EditorControlProps['addDraftEntryMediaFile']; clearMediaControl: EditorControlProps['clearMediaControl']; openMediaLibrary: EditorControlProps['openMediaLibrary']; removeInsertedMedia: EditorControlProps['removeInsertedMedia']; diff --git a/src/lib/util/fetch.util.ts b/src/lib/util/fetch.util.ts new file mode 100644 index 00000000..5b4a26d3 --- /dev/null +++ b/src/lib/util/fetch.util.ts @@ -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 }; +} diff --git a/src/lib/util/validation.util.ts b/src/lib/util/validation.util.ts index e3a61e25..2ee42a60 100644 --- a/src/lib/util/validation.util.ts +++ b/src/lib/util/validation.util.ts @@ -8,7 +8,7 @@ import type { FieldValidationMethod, FieldValidationMethodProps, ValueOrNestedValue, - Widget + Widget, } from '../../interface'; export function isEmpty(value: ValueOrNestedValue) { diff --git a/src/store/slices/snackbars.ts b/src/store/slices/snackbars.ts index 25c3965f..d806f4b2 100644 --- a/src/store/slices/snackbars.ts +++ b/src/store/slices/snackbars.ts @@ -9,9 +9,11 @@ type MessageType = 'error' | 'warning' | 'info' | 'success'; export interface SnackbarMessage { id: string; type: MessageType; - message: string | { - key: string; - } & Record; + message: + | string + | ({ + key: string; + } & Record); } // Define a type for the slice state diff --git a/src/widgets/markdown/MarkdownControl.tsx b/src/widgets/markdown/MarkdownControl.tsx index a5a3e60e..357faa64 100644 --- a/src/widgets/markdown/MarkdownControl.tsx +++ b/src/widgets/markdown/MarkdownControl.tsx @@ -1,20 +1,24 @@ import { styled } from '@mui/material/styles'; import { Editor } from '@toast-ui/react-editor'; -import mime from 'mime-types'; -import React, { useCallback, useMemo, useState } from 'react'; +import isEmpty from 'lodash/isEmpty'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import uuid from 'uuid'; import FieldLabel from '../../components/UI/FieldLabel'; import Outline from '../../components/UI/Outline'; -import { sanitizeSlug } from '../../lib/urlHelper'; -import { selectMediaFilePath } from '../../lib/util/media.util'; -import { createAssetProxy } from '../../valueObjects/AssetProxy'; +import { IMAGE_EXTENSION_REGEX } from '../../constants/files'; +import useImagePlugin from '../../editor-components/editorPlugin'; +import { doesUrlFileExist } from '../../lib/util/fetch.util'; +import { isNotNullish } from '../../lib/util/null.util'; +import { isNotEmpty } from '../../lib/util/string.util'; import 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'; +const imageFilePattern = /(!)?\[([^\]]*)\]\(([^)]+)\)/; + const StyledEditorWrapper = styled('div')` position: relative; width: 100%; @@ -42,11 +46,9 @@ const MarkdownControl = ({ onChange, hasErrors, field, - addAsset, - addDraftEntryMediaFile, - config, - collection, - entry, + openMediaLibrary, + mediaPaths, + getAsset, }: WidgetControlProps) => { const [internalValue, setInternalValue] = useState(value ?? ''); const editorRef = useMemo(() => React.createRef(), []) as RefObject; @@ -70,43 +72,88 @@ const MarkdownControl = ({ editorRef.current?.getInstance().focus(); }, [editorRef]); - const imageUpload = useCallback( - (blob: Blob | File, callback: (url: string, text?: string) => void) => { - let file: File; - if (blob instanceof Blob) { - 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 controlID: string = useMemo(() => uuid(), []); + const mediaLibraryFieldOptions: MediaLibrary = useMemo( + () => field.media_library ?? {}, + [field.media_library], ); + 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 ( @@ -130,16 +177,42 @@ const MarkdownControl = ({ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], - ['table', 'image', 'link'], + ['table', imageToolbarButton, 'link'], ['code', 'codeblock'], ]} ref={editorRef} onFocus={handleOnFocus} onBlur={handleOnBlur} - hooks={{ - addImageBlobHook: imageUpload, - }} 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'); + }, + }, + ]} /> diff --git a/src/widgets/select/SelectControl.tsx b/src/widgets/select/SelectControl.tsx index ef2f6333..29b0d618 100644 --- a/src/widgets/select/SelectControl.tsx +++ b/src/widgets/select/SelectControl.tsx @@ -39,20 +39,15 @@ const SelectControl = ({ const isEmpty = isMultiple && Array.isArray(selectedOption) ? !selectedOption?.length : !selectedOption; - console.log('[value]', selectedOption, field.required, isEmpty, isMultiple); - if (field.required && isEmpty && isMultiple) { setInternalValue([]); onChange([]); - console.log('emtpy array!'); } else if (isEmpty) { setInternalValue(''); onChange(''); - console.log('emtpy string!'); } else if (typeof selectedOption === 'string' || isMultiple) { setInternalValue(selectedOption); onChange(selectedOption); - console.log('valid value!', selectedOption); } }, [field, onChange], @@ -85,8 +80,8 @@ const SelectControl = ({ {selectValues.map(selectValue => { const label = optionsByValue[selectValue]?.label ?? selectValue; - return - })} + return ; + })} ) : ( selectValues diff --git a/src/widgets/text/TextControl.tsx b/src/widgets/text/TextControl.tsx index 8e694d55..7ed07219 100644 --- a/src/widgets/text/TextControl.tsx +++ b/src/widgets/text/TextControl.tsx @@ -4,7 +4,12 @@ import React, { useCallback, useState } from 'react'; import type { ChangeEvent } from 'react'; import type { StringOrTextField, WidgetControlProps } from '../../interface'; -const TextControl = ({ label, value, onChange, hasErrors }: WidgetControlProps) => { +const TextControl = ({ + label, + value, + onChange, + hasErrors, +}: WidgetControlProps) => { const [internalValue, setInternalValue] = useState(value ?? ''); const handleChange = useCallback( diff --git a/website/src/components/github-button.js b/website/src/components/github-button.js index 3474920b..6944c149 100644 --- a/website/src/components/github-button.js +++ b/website/src/components/github-button.js @@ -7,7 +7,10 @@ class GitHubStarButton extends PureComponent { href="https://github.com/StaticJsCMS/static-cms" aria-label="Star StaticJsCMS/static-cms on GitHub" > - Star StaticJsCMS/static-cms on GitHub + Star StaticJsCMS/static-cms on GitHub ); } diff --git a/yarn.lock b/yarn.lock index 180f1c2e..fc3ac2e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,11 +2250,6 @@ dependencies: "@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@*": version "3.0.1" 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" 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" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==