Bugfix/image widget starting value (#57)

* Fix image asset loading
* Fix scroll sync for frame
This commit is contained in:
Daniel Lautzenheiser 2022-11-01 14:07:30 -04:00 committed by GitHub
parent e62563e4a3
commit 6135a6c8d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 354 additions and 162 deletions

View File

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

View File

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 310 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,10 +1,12 @@
backend: backend:
name: gitlab name: gitlab
branch: master branch: main
repo: owner/repo repo: static-cms/static-cms-gitlab
auth_type: pkce
app_id: 91cc479ec663625098d456850c4dc4943fd8462064ebd9693b330e66f9d8f11a
media_folder: static/media media_folder: assets/upload
public_folder: /media public_folder: /assets/upload
collections: collections:
- name: posts - name: posts
label: Posts label: Posts
@ -92,7 +94,8 @@ collections:
label: Settings label: Settings
delete: false delete: false
editor: editor:
preview: false preview: true
frame: false
files: files:
- name: general - name: general
label: Site Settings label: Site Settings

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,7 @@ const PostPreview = createClass({
}, },
}); });
// TODO Hook this back up, getAsset returns a promise now
const GeneralPreview = createClass({ const GeneralPreview = createClass({
render: function () { render: function () {
const entry = this.props.entry; const entry = this.props.entry;
@ -45,6 +46,7 @@ const GeneralPreview = createClass({
); );
}, },
}); });
const AuthorsPreview = createClass({ const AuthorsPreview = createClass({
render: function () { render: function () {
return h( return h(
@ -90,7 +92,7 @@ const RelationKitchenSinkPostPreview = createClass({
CMS.registerPreviewStyle('.toastui-editor-contents h1 { color: blue }', { raw: true }); CMS.registerPreviewStyle('.toastui-editor-contents h1 { color: blue }', { raw: true });
CMS.registerPreviewTemplate('posts', PostPreview); CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerPreviewTemplate('general', GeneralPreview); // CMS.registerPreviewTemplate('general', GeneralPreview);
CMS.registerPreviewTemplate('authors', AuthorsPreview); CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview. // Pass the name of a registered control to reuse with a new widget preview.
CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview); CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);

View File

@ -6,7 +6,7 @@ import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './m
import type { AnyAction } from 'redux'; import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk';
import type { Field, Collection, Entry } from '../interface'; import type { Collection, Entry, Field } from '../interface';
import type { RootState } from '../store'; import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy'; import type AssetProxy from '../valueObjects/AssetProxy';
@ -42,33 +42,6 @@ export function loadAssetFailure(path: string, error: Error) {
return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const; return { type: LOAD_ASSET_FAILURE, payload: { path, error } } as const;
} }
export function loadAsset(resolvedPath: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
const asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
const asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(loadAssetFailure(resolvedPath, error));
}
}
};
}
const emptyAsset = createAssetProxy({ const emptyAsset = createAssetProxy({
path: 'empty.svg', path: 'empty.svg',
file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', { file: new File([`<svg xmlns="http://www.w3.org/2000/svg"></svg>`], 'empty.svg', {
@ -76,50 +49,93 @@ const emptyAsset = createAssetProxy({
}), }),
}); });
async function loadAsset(
resolvedPath: string,
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
getState: () => RootState,
): Promise<AssetProxy> {
try {
dispatch(loadAssetRequest(resolvedPath));
// load asset url from backend
await waitForMediaLibraryToLoad(dispatch, getState());
const file = selectMediaFileByPath(getState(), resolvedPath);
let asset: AssetProxy;
if (file) {
const url = await getMediaDisplayURL(dispatch, getState(), file);
asset = createAssetProxy({ path: resolvedPath, url: url || resolvedPath });
dispatch(addAsset(asset));
} else {
const { url } = await getMediaFile(getState(), resolvedPath);
asset = createAssetProxy({ path: resolvedPath, url });
dispatch(addAsset(asset));
}
dispatch(loadAssetSuccess(resolvedPath));
return asset;
} catch (error: unknown) {
console.error(error);
if (error instanceof Error) {
dispatch(loadAssetFailure(resolvedPath, error));
}
return emptyAsset;
}
}
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset( export function getAsset(
collection: Collection | null | undefined, collection: Collection | null | undefined,
entry: Entry | null | undefined, entry: Entry | null | undefined,
path: string, path: string,
field?: Field, field?: Field,
) { ) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => { return (
dispatch: ThunkDispatch<RootState, {}, AnyAction>,
getState: () => RootState,
): Promise<AssetProxy> => {
if (!collection || !entry || !path) { if (!collection || !entry || !path) {
return emptyAsset; return Promise.resolve(emptyAsset);
} }
const state = getState(); const state = getState();
if (!state.config.config) { if (!state.config.config) {
return emptyAsset; return Promise.resolve(emptyAsset);
} }
const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field); const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field);
let { asset, isLoading, error } = state.medias[resolvedPath] || {}; let { asset, isLoading, error } = state.medias[resolvedPath] || {};
if (isLoading) { if (isLoading) {
return emptyAsset; return promiseCache[resolvedPath];
} }
if (asset) { if (asset) {
// There is already an AssetProxy in memory for this path. Use it. // There is already an AssetProxy in memory for this path. Use it.
return asset; return Promise.resolve(asset);
} }
if (isAbsolutePath(resolvedPath)) { const p = new Promise<AssetProxy>(resolve => {
// asset path is a public url so we can just use it as is if (isAbsolutePath(resolvedPath)) {
asset = createAssetProxy({ path: resolvedPath, url: path }); // asset path is a public url so we can just use it as is
dispatch(addAsset(asset));
} else {
if (error) {
// on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path }); asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset)); dispatch(addAsset(asset));
resolve(asset);
} else { } else {
dispatch(loadAsset(resolvedPath)); if (error) {
asset = emptyAsset; // on load error default back to original path
asset = createAssetProxy({ path: resolvedPath, url: path });
dispatch(addAsset(asset));
resolve(asset);
} else {
loadAsset(resolvedPath, dispatch, getState).then(asset => {
resolve(asset);
});
}
} }
} });
return asset; promiseCache[resolvedPath] = p;
return p;
}; };
} }

View File

@ -3,7 +3,7 @@ import CardActionArea from '@mui/material/CardActionArea';
import CardContent from '@mui/material/CardContent'; import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia'; import CardMedia from '@mui/material/CardMedia';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -29,15 +29,24 @@ const EntryCard = ({
}: NestedCollectionProps) => { }: NestedCollectionProps) => {
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const [imageUrl, setImageUrl] = useState<string>();
useEffect(() => {
if (!image) {
return;
}
const getImage = async () => {
setImageUrl((await getAsset(collection, entry, image, imageField)).toString());
};
getImage();
}, [collection, entry, getAsset, image, imageField]);
return ( return (
<Card> <Card>
<CardActionArea component={Link} to={path}> <CardActionArea component={Link} to={path}>
{viewStyle === VIEW_STYLE_GRID && image && imageField ? ( {viewStyle === VIEW_STYLE_GRID && image && imageField ? (
<CardMedia <CardMedia component="img" height="140" image={imageUrl} />
component="img"
height="140"
image={getAsset(collection, entry, image, imageField).toString()}
/>
) : null} ) : null}
<CardContent> <CardContent>
{collectionLabel ? ( {collectionLabel ? (

View File

@ -23,6 +23,7 @@ import type { ConnectedProps } from 'react-redux';
import type { InferredField } from '../../../constants/fieldInference'; import type { InferredField } from '../../../constants/fieldInference';
import type { import type {
Collection, Collection,
Config,
Entry, Entry,
EntryData, EntryData,
Field, Field,
@ -84,6 +85,7 @@ const StyledPreviewContent = styled('div')`
* exposed for use in custom preview templates. * exposed for use in custom preview templates.
*/ */
function getWidgetFor( function getWidgetFor(
config: Config,
collection: Collection, collection: Collection,
name: string, name: string,
fields: Field[], fields: Field[],
@ -112,6 +114,7 @@ function getWidgetFor(
fieldWithWidgets = { fieldWithWidgets = {
...fieldWithWidgets, ...fieldWithWidgets,
fields: getNestedWidgets( fields: getNestedWidgets(
config,
collection, collection,
fields, fields,
entry, entry,
@ -125,6 +128,7 @@ function getWidgetFor(
fieldWithWidgets = { fieldWithWidgets = {
...fieldWithWidgets, ...fieldWithWidgets,
fields: getTypedNestedWidgets( fields: getTypedNestedWidgets(
config,
collection, collection,
field, field,
entry, entry,
@ -161,7 +165,7 @@ function getWidgetFor(
); );
} }
return renderedValue return renderedValue
? getWidget(fieldWithWidgets, collection, renderedValue, entry, getAsset) ? getWidget(config, fieldWithWidgets, collection, renderedValue, entry, getAsset)
: null; : null;
} }
@ -180,6 +184,7 @@ function isReactFragment(value: any): value is ReactFragment {
} }
function getWidget( function getWidget(
config: Config,
field: RenderedField, field: RenderedField,
collection: Collection, collection: Collection,
value: ValueOrNestedValue | ReactNode, value: ValueOrNestedValue | ReactNode,
@ -203,6 +208,7 @@ function getWidget(
key={key} key={key}
field={field} field={field}
getAsset={getAsset} getAsset={getAsset}
config={config}
collection={collection} collection={collection}
value={ value={
value && value &&
@ -223,6 +229,7 @@ function getWidget(
* Use getWidgetFor as a mapping function for recursive widget retrieval * Use getWidgetFor as a mapping function for recursive widget retrieval
*/ */
function widgetsForNestedFields( function widgetsForNestedFields(
config: Config,
collection: Collection, collection: Collection,
fields: Field[], fields: Field[],
entry: Entry, entry: Entry,
@ -234,6 +241,7 @@ function widgetsForNestedFields(
return widgetFields return widgetFields
.map(field => .map(field =>
getWidgetFor( getWidgetFor(
config,
collection, collection,
field.name, field.name,
fields, fields,
@ -251,6 +259,7 @@ function widgetsForNestedFields(
* Retrieves widgets for nested fields (children of object/list fields) * Retrieves widgets for nested fields (children of object/list fields)
*/ */
function getTypedNestedWidgets( function getTypedNestedWidgets(
config: Config,
collection: Collection, collection: Collection,
field: ListField, field: ListField,
entry: Entry, entry: Entry,
@ -259,13 +268,14 @@ function getTypedNestedWidgets(
values: EntryData[], values: EntryData[],
) { ) {
return values return values
.flatMap((value, index) => { ?.flatMap((value, index) => {
const itemType = getTypedFieldForValue(field, value ?? {}, index); const itemType = getTypedFieldForValue(field, value ?? {}, index);
if (!itemType) { if (!itemType) {
return null; return null;
} }
return widgetsForNestedFields( return widgetsForNestedFields(
config,
collection, collection,
itemType.fields, itemType.fields,
entry, entry,
@ -282,6 +292,7 @@ function getTypedNestedWidgets(
* Retrieves widgets for nested fields (children of object/list fields) * Retrieves widgets for nested fields (children of object/list fields)
*/ */
function getNestedWidgets( function getNestedWidgets(
config: Config,
collection: Collection, collection: Collection,
fields: Field[], fields: Field[],
entry: Entry, entry: Entry,
@ -294,6 +305,7 @@ function getNestedWidgets(
if (Array.isArray(values)) { if (Array.isArray(values)) {
return values.flatMap(value => return values.flatMap(value =>
widgetsForNestedFields( widgetsForNestedFields(
config,
collection, collection,
fields, fields,
entry, entry,
@ -307,6 +319,7 @@ function getNestedWidgets(
// Fields nested within an object field will be paired with a single Record of values. // Fields nested within an object field will be paired with a single Record of values.
return widgetsForNestedFields( return widgetsForNestedFields(
config,
collection, collection,
fields, fields,
entry, entry,
@ -332,9 +345,20 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
const widgetFor = useCallback( const widgetFor = useCallback(
(name: string) => { (name: string) => {
return getWidgetFor(collection, name, fields, entry, inferedFields, handleGetAsset); if (!config.config) {
return null;
}
return getWidgetFor(
config.config,
collection,
name,
fields,
entry,
inferedFields,
handleGetAsset,
);
}, },
[collection, entry, fields, handleGetAsset, inferedFields], [collection, config, entry, fields, handleGetAsset, inferedFields],
); );
/** /**
@ -422,34 +446,41 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
} }
return ReactDOM.createPortal( return ReactDOM.createPortal(
<ScrollSyncPane> <StyledPreviewContent className="preview-content">
<StyledPreviewContent className="preview-content"> {!entry || !entry.data ? null : (
{!entry || !entry.data ? null : ( <ErrorBoundary config={config}>
<ErrorBoundary config={config}> {previewInFrame ? (
{previewInFrame ? ( <PreviewPaneFrame
<PreviewPaneFrame key="preview-frame"
key="preview-frame" id="preview-pane"
id="preview-pane" head={previewStyles}
head={previewStyles} initialContent={initialFrameContent}
initialContent={initialFrameContent} >
> {!collection ? (
{!collection ? ( t('collection.notFound')
t('collection.notFound') ) : (
) : ( <FrameContextConsumer>
<FrameContextConsumer> {({ document, window }) => {
{({ document, window }) => { return (
return ( <ScrollSyncPane
key="preview-frame-scroll-sync"
attachTo={
(document?.scrollingElement ?? undefined) as HTMLElement | undefined
}
>
<EditorPreviewContent <EditorPreviewContent
key="preview-frame-content" key="preview-frame-content"
previewComponent={previewComponent} previewComponent={previewComponent}
previewProps={{ ...previewProps, document, window }} previewProps={{ ...previewProps, document, window }}
/> />
); </ScrollSyncPane>
}} );
</FrameContextConsumer> }}
)} </FrameContextConsumer>
</PreviewPaneFrame> )}
) : ( </PreviewPaneFrame>
) : (
<ScrollSyncPane key="preview-wrapper-scroll-sync">
<PreviewPaneWrapper key="preview-wrapper" id="preview-pane"> <PreviewPaneWrapper key="preview-wrapper" id="preview-pane">
{!collection ? ( {!collection ? (
t('collection.notFound') t('collection.notFound')
@ -464,11 +495,11 @@ const PreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
</> </>
)} )}
</PreviewPaneWrapper> </PreviewPaneWrapper>
)} </ScrollSyncPane>
</ErrorBoundary> )}
)} </ErrorBoundary>
</StyledPreviewContent> )}
</ScrollSyncPane>, </StyledPreviewContent>,
element, element,
'preview-content', 'preview-content',
); );

View File

@ -13,6 +13,7 @@ import type { I18N_STRUCTURE } from './lib/i18n';
import type { AllowedEvent } from './lib/registry'; import type { AllowedEvent } from './lib/registry';
import type Cursor from './lib/util/Cursor'; import type Cursor from './lib/util/Cursor';
import type AssetProxy from './valueObjects/AssetProxy'; import type AssetProxy from './valueObjects/AssetProxy';
import type { MediaHolder } from './widgets/markdown/hooks/useMedia';
export interface SlugConfig { export interface SlugConfig {
encoding: string; encoding: string;
@ -227,7 +228,7 @@ export type Hook = string | boolean;
export type TranslatedProps<T> = T & ReactPolyglotTranslateProps; export type TranslatedProps<T> = T & ReactPolyglotTranslateProps;
export type GetAssetFunction = (path: string, field?: Field) => AssetProxy; export type GetAssetFunction = (path: string, field?: Field) => Promise<AssetProxy>;
export interface WidgetControlProps<T, F extends Field = Field> { export interface WidgetControlProps<T, F extends Field = Field> {
clearFieldErrors: EditorControlProps['clearFieldErrors']; clearFieldErrors: EditorControlProps['clearFieldErrors'];
@ -262,6 +263,7 @@ export interface WidgetControlProps<T, F extends Field = Field> {
} }
export interface WidgetPreviewProps<T = unknown, F extends Field = Field> { export interface WidgetPreviewProps<T = unknown, F extends Field = Field> {
config: Config;
collection: Collection; collection: Collection;
entry: Entry; entry: Entry;
field: RenderedField<F>; field: RenderedField<F>;
@ -322,28 +324,6 @@ export interface WidgetParam<T = unknown, F extends Field = Field> {
options?: WidgetOptions<T, F>; options?: WidgetOptions<T, F>;
} }
export interface PreviewTemplateComponentProps {
entry: Entry;
collection: Collection;
widgetFor: (name: string) => ReactNode;
widgetsFor: (name: string) =>
| {
data: EntryData | null;
widgets: Record<string, React.ReactNode>;
}
| {
data: EntryData | null;
widgets: Record<string, React.ReactNode>;
}[];
getAsset: GetAssetFunction;
boundGetAsset: (collection: Collection, path: string) => GetAssetFunction;
config: Config;
fields: Field[];
isLoadingAsset: boolean;
window: Window;
document: Document;
}
export interface PersistOptions { export interface PersistOptions {
newEntry?: boolean; newEntry?: boolean;
commitMessage: string; commitMessage: string;
@ -907,8 +887,9 @@ export interface PreviewStyle {
} }
export interface MarkdownPluginFactoryProps { export interface MarkdownPluginFactoryProps {
getAsset: GetAssetFunction; config: Config;
field: MarkdownField; field: MarkdownField;
media: MediaHolder;
mode: 'editor' | 'preview'; mode: 'editor' | 'preview';
} }

View File

@ -57,7 +57,7 @@ function fromFetchArguments(wholeURL: string, options?: RequestInit): ApiRequest
function encodeParams(params: Required<ApiRequestURL>['params']): string { function encodeParams(params: Required<ApiRequestURL>['params']): string {
return Object.entries(params) return Object.entries(params)
.map(([v, k]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&'); .join('&');
} }
@ -70,7 +70,6 @@ function toFetchArguments(req: ApiRequestObject): {
init?: RequestInit | undefined; init?: RequestInit | undefined;
} { } {
const { url, params, ...rest } = req; const { url, params, ...rest } = req;
return { input: toURL({ url, params }), init: rest }; return { input: toURL({ url, params }), init: rest };
} }
@ -110,9 +109,17 @@ const withWrapper =
return fromFetchArguments(req, { [key]: value }); return fromFetchArguments(req, { [key]: value });
} }
let finalValue = value;
if (key === 'headers') {
finalValue = {
...(req.headers ?? {}),
...(value as HeadersInit),
} as ApiRequestObject[K];
}
return { return {
...req, ...req,
[key]: value, [key]: finalValue,
}; };
}; };

View File

@ -18,8 +18,14 @@ const FileLink = ({ value, getAsset, field }: FileLinkProps) => {
return; return;
} }
setAssetSource(getAsset(value, field)?.toString() ?? ''); const getImage = async() => {
}, [field, getAsset, value]); const asset = (await getAsset(value, field))?.toString() ?? '';
setAssetSource(asset);
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return ( return (
<a href={assetSource} rel="noopener noreferrer" target="_blank"> <a href={assetSource} rel="noopener noreferrer" target="_blank">

View File

@ -136,8 +136,14 @@ const SortableImage = SortableElement<SortableImageProps>(
({ itemValue, getAsset, field, onRemove, onReplace }: SortableImageProps) => { ({ itemValue, getAsset, field, onRemove, onReplace }: SortableImageProps) => {
const [assetSource, setAssetSource] = useState(''); const [assetSource, setAssetSource] = useState('');
useEffect(() => { useEffect(() => {
setAssetSource(getAsset(itemValue, field)?.toString() ?? ''); const getImage = async() => {
}, [field, getAsset, itemValue]); const asset = (await getAsset(itemValue, field))?.toString() ?? '';
setAssetSource(asset);
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemValue]);
return ( return (
<div> <div>
@ -389,11 +395,16 @@ export default function withFileControl({ forImage = false }: WithImageOptions =
return; return;
} }
const newValue = getAsset(internalValue, field)?.toString() ?? ''; const getImage = async() => {
if (newValue !== internalValue) { const newValue = (await getAsset(internalValue, field))?.toString() ?? '';
setAssetSource(newValue); if (newValue !== internalValue) {
} setAssetSource(newValue);
}, [field, getAsset, internalValue]); }
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [internalValue]);
const renderedImagesLinks = useMemo(() => { const renderedImagesLinks = useMemo(() => {
if (forImage) { if (forImage) {

View File

@ -26,8 +26,14 @@ interface ImageAssetProps {
function ImageAsset({ getAsset, value, field }: ImageAssetProps) { function ImageAsset({ getAsset, value, field }: ImageAssetProps) {
const [assetSource, setAssetSource] = useState(''); const [assetSource, setAssetSource] = useState('');
useEffect(() => { useEffect(() => {
setAssetSource(getAsset(value, field)?.toString() ?? ''); const getImage = async() => {
}, [field, getAsset, value]); const asset = (await getAsset(value, field))?.toString() ?? '';
setAssetSource(asset);
};
getImage();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return <StyledImage src={assetSource} />; return <StyledImage src={assetSource} />;
} }

View File

@ -11,6 +11,7 @@ import { doesUrlFileExist } from '../../lib/util/fetch.util';
import { isNotNullish } from '../../lib/util/null.util'; import { isNotNullish } from '../../lib/util/null.util';
import { isNotEmpty } from '../../lib/util/string.util'; import { isNotEmpty } from '../../lib/util/string.util';
import useEditorOptions from './hooks/useEditorOptions'; import useEditorOptions from './hooks/useEditorOptions';
import useMedia, { MediaHolder } from './hooks/useMedia';
import usePlugins from './hooks/usePlugins'; import usePlugins from './hooks/usePlugins';
import useToolbarItems from './hooks/useToolbarItems'; import useToolbarItems from './hooks/useToolbarItems';
@ -49,6 +50,7 @@ const MarkdownControl = ({
openMediaLibrary, openMediaLibrary,
mediaPaths, mediaPaths,
getAsset, getAsset,
config,
}: 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>;
@ -95,7 +97,7 @@ const MarkdownControl = ({
async (path: string) => { async (path: string) => {
const { type, exists } = await doesUrlFileExist(path); const { type, exists } = await doesUrlFileExist(path);
if (!exists) { if (!exists) {
const asset = getAsset(path, field); const asset = await getAsset(path, field);
if (isNotNullish(asset)) { if (isNotNullish(asset)) {
return { return {
type: IMAGE_EXTENSION_REGEX.test(path) ? 'image' : 'file', type: IMAGE_EXTENSION_REGEX.test(path) ? 'image' : 'file',
@ -152,7 +154,22 @@ const MarkdownControl = ({
}, [field, mediaPath]); }, [field, mediaPath]);
const { initialEditType, height, ...markdownEditorOptions } = useEditorOptions(); const { initialEditType, height, ...markdownEditorOptions } = useEditorOptions();
const plugins = usePlugins(markdownEditorOptions.plugins, { getAsset, field, mode: 'editor' });
const media = useMedia({ value, getAsset, field });
const mediaHolder = useMemo(() => new MediaHolder(), []);
useEffect(() => {
mediaHolder.setBulkMedia(media);
editorRef.current?.getInstance().setMarkdown(internalValue);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [media]);
const plugins = usePlugins(markdownEditorOptions.plugins, {
media: mediaHolder,
config,
field,
mode: 'editor',
});
const toolbarItems = useToolbarItems(markdownEditorOptions.toolbarItems, handleOpenMedialLibrary); const toolbarItems = useToolbarItems(markdownEditorOptions.toolbarItems, handleOpenMedialLibrary);
return useMemo( return useMemo(

View File

@ -1,36 +1,59 @@
import { Viewer } from '@toast-ui/react-editor'; import { Viewer } from '@toast-ui/react-editor';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer';
import useEditorOptions from './hooks/useEditorOptions'; import useEditorOptions from './hooks/useEditorOptions';
import useMedia, { MediaHolder } from './hooks/useMedia';
import usePlugins from './hooks/usePlugins'; import usePlugins from './hooks/usePlugins';
import type { MarkdownField, WidgetPreviewProps } from '../../interface'; import type { MarkdownField, WidgetPreviewProps } from '../../interface';
const MarkdownPreview = ({ value, getAsset, field }: WidgetPreviewProps<string, MarkdownField>) => { const MarkdownPreview = ({
value,
getAsset,
config,
field,
}: WidgetPreviewProps<string, MarkdownField>) => {
const options = useEditorOptions(); const options = useEditorOptions();
const plugins = usePlugins(options.plugins, { getAsset, field, mode: 'preview' });
const mediaHolder = useMemo(() => new MediaHolder(), []);
const media = useMedia({ value, getAsset, field });
useEffect(() => {
mediaHolder.setBulkMedia(media);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [media]);
const plugins = usePlugins(options.plugins, {
config,
media: mediaHolder,
field,
mode: 'preview',
});
const viewer = useRef<Viewer | null>(null); const viewer = useRef<Viewer | null>(null);
useEffect(() => { useEffect(() => {
viewer.current?.getInstance().setMarkdown(value ?? ''); viewer.current?.getInstance().setMarkdown(value ?? '');
}, [value]); }, [value, media]);
if (!value) { return useMemo(() => {
return null; if (!value) {
} return null;
}
return ( return (
<WidgetPreviewContainer> <WidgetPreviewContainer>
<Viewer <Viewer
ref={viewer} ref={viewer}
initialValue={value} initialValue={value}
customHTMLSanitizer={(content: string) => content} customHTMLSanitizer={(content: string) => content}
plugins={plugins} plugins={plugins}
/> />
</WidgetPreviewContainer> </WidgetPreviewContainer>
); );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plugins]);
}; };
export default MarkdownPreview; export default MarkdownPreview;

View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import type { GetAssetFunction, MarkdownField } from '../../../interface';
import type AssetProxy from '../../../valueObjects/AssetProxy';
interface UseMediaProps {
value: string | undefined | null;
getAsset: GetAssetFunction;
field: MarkdownField;
}
export class MediaHolder {
private media: Record<string, AssetProxy> = {};
public setBulkMedia(otherMedia: Record<string, AssetProxy>) {
for (const [url, asset] of Object.entries(otherMedia)) {
this.setMedia(url, asset);
}
}
public setMedia(url: string, asset: AssetProxy) {
this.media[url] = asset;
}
public getMedia(url: string) {
return this.media[url];
}
}
const useMedia = ({ value, getAsset, field }: UseMediaProps) => {
const [media, setMedia] = useState<Record<string, AssetProxy>>({});
useEffect(() => {
if (!value) {
return;
}
let alive = true;
const getMedia = async () => {
const regExp = /!\[[^\]()]*\]\(([^)]+)\)/g;
let matches = regExp.exec(value);
const mediaToLoad: string[] = [];
while (matches && matches.length === 2) {
mediaToLoad.push(matches[1]);
matches = regExp.exec(value);
}
const uniqueMediaToLoad = mediaToLoad.filter(
(value, index, self) => self.indexOf(value) === index,
);
for (const url of uniqueMediaToLoad) {
const asset = await getAsset(url, field);
if (!alive) {
break;
}
setMedia(oldValue => ({
...oldValue,
[url]: asset,
}));
}
};
getMedia();
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return media;
};
export default useMedia;

View File

@ -6,17 +6,19 @@ import type { MarkdownEditorOptions, MarkdownPluginFactoryProps } from '../../..
const usePlugins = ( const usePlugins = (
editorPlugins: MarkdownEditorOptions['plugins'] = [], editorPlugins: MarkdownEditorOptions['plugins'] = [],
{ getAsset, field, mode }: MarkdownPluginFactoryProps, { config, media, field, mode }: MarkdownPluginFactoryProps,
) => { ) => {
return useMemo(() => { return useMemo(() => {
const plugins = [imagePlugin({ getAsset, field, mode })]; const plugins = [imagePlugin({ config, media, field, mode })];
if (plugins) { if (plugins) {
plugins.push(...editorPlugins.map(editorPlugin => editorPlugin({ getAsset, field, mode }))); plugins.push(
...editorPlugins.map(editorPlugin => editorPlugin({ config, media, field, mode })),
);
} }
return plugins; return plugins;
}, [editorPlugins, field, getAsset, mode]); }, [config, editorPlugins, field, media, mode]);
}; };
export default usePlugins; export default usePlugins;

View File

@ -6,25 +6,23 @@ function isLinkNode(node: MdNode): node is LinkMdNode {
return 'destination' in node; return 'destination' in node;
} }
const toHTMLRenderers: (props: MarkdownPluginFactoryProps) => CustomHTMLRenderer = ({ const toHTMLRenderer: (props: MarkdownPluginFactoryProps) => CustomHTMLRenderer = ({ media }) => ({
getAsset,
field,
}) => ({
image: (node: MdNode, { entering, skipChildren }) => { image: (node: MdNode, { entering, skipChildren }) => {
if (entering && isLinkNode(node)) { if (entering && isLinkNode(node)) {
skipChildren(); skipChildren();
let imageUrl = node.destination ?? '';
if (node.destination) {
imageUrl = media.getMedia(node.destination)?.toString() ?? node.destination;
}
return { return {
type: 'openTag', type: 'openTag',
tagName: 'img', tagName: 'img',
outerNewLine: true, outerNewLine: true,
attributes: { attributes: {
src: node.destination, src: node.destination,
onerror: `this.onerror=null; this.src='${ onerror: `this.onerror=null; this.src='${imageUrl}'`,
node.destination
? getAsset(node.destination, field)?.toString() ?? node.destination
: ''
}'`,
}, },
selfClose: true, selfClose: true,
}; };
@ -36,7 +34,7 @@ const toHTMLRenderers: (props: MarkdownPluginFactoryProps) => CustomHTMLRenderer
const imagePlugin: MarkdownPluginFactory = props => { const imagePlugin: MarkdownPluginFactory = props => {
return () => ({ return () => ({
toHTMLRenderers: toHTMLRenderers(props), toHTMLRenderers: toHTMLRenderer(props),
}); });
}; };