feat: improved live preview support

This commit is contained in:
Daniel Lautzenheiser 2023-07-20 12:25:25 -04:00
parent 5046dc1558
commit f696d5c445
11 changed files with 105 additions and 54 deletions

View File

@ -21,7 +21,7 @@ import {
import { loadMedia } from '@staticcms/core/actions/mediaLibrary';
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll';
import useEntryCallback from '@staticcms/core/lib/hooks/useEntryCallback';
import { selectFields } from '@staticcms/core/lib/util/collection.util';
import { getFileFromSlug, selectFields } from '@staticcms/core/lib/util/collection.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
@ -211,7 +211,24 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
};
}, [collection, createBackup, entryDraft.entry, hasChanged]);
const hasLivePreview = useMemo(() => {
let livePreview = typeof collection.editor?.live_preview === 'string';
if ('files' in collection) {
if (entryDraft.entry) {
const file = getFileFromSlug(collection, entryDraft.entry.slug);
if (file?.editor) {
livePreview = livePreview || typeof file.editor.live_preview === 'string';
}
}
}
return livePreview;
}, [collection, entryDraft.entry]);
useEntryCallback({
hasLivePreview,
collection,
slug,
callback: () => {

View File

@ -8,6 +8,7 @@ import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views';
import { getPreviewStyles, getPreviewTemplate } from '@staticcms/core/lib/registry';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectTemplateName } from '@staticcms/core/lib/util/collection.util';
import LivePreviewLoadedEvent from '@staticcms/core/lib/util/events/LivePreviewLoadedEvent';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
@ -195,6 +196,10 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
useWindowEvent('data:update', passEventToIframe);
const handleLivePreviewIframeLoaded = useCallback(() => {
window.dispatchEvent(new LivePreviewLoadedEvent());
}, []);
return useMemo(() => {
if (!element) {
return null;
@ -222,9 +227,11 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
<ErrorBoundary config={config}>
{livePreviewUrlTemplate ? (
<iframe
key="live-preview-frame"
ref={livePreviewIframe}
src={`${livePreviewUrlTemplate}?useCmsData=true`}
className="w-full h-full"
onLoad={handleLivePreviewIframeLoaded}
/>
) : previewInFrame ? (
<Frame
@ -280,6 +287,7 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
config,
editorSize,
element,
handleLivePreviewIframeLoaded,
initialFrameContent,
livePreviewUrlTemplate,
previewComponent,

View File

@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import { currentBackend } from './backend';
import bootstrap from './bootstrap';
import useData from './lib/hooks/useData';
import useEntries from './lib/hooks/useEntries';
import useFolderSupport from './lib/hooks/useFolderSupport';
import useHasChildErrors from './lib/hooks/useHasChildErrors';
@ -13,6 +12,7 @@ import useMediaFiles from './lib/hooks/useMediaFiles';
import useMediaInsert from './lib/hooks/useMediaInsert';
import useUUID from './lib/hooks/useUUID';
import Registry from './lib/registry';
import useData from './live/useData';
export * from './backends';
export * from './interface';

View File

@ -1,4 +1,4 @@
export { default as useData } from './useData';
export { default as useData } from '../../live/useData';
export { default as useEntries } from './useEntries';
export { default as useFolderSupport } from './useFolderSupport';
export { default as useHasChildErrors } from './useHasChildErrors';

View File

@ -1,45 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ValueOrNestedValue } from '@staticcms/core/interface';
export default function useData(value: ValueOrNestedValue, path: string) {
const [data, setData] = useState(value);
const isCms = useMemo(() => {
if (!window) {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get('useCmsData') === 'true';
}, []);
const onDataChange = useCallback(
(event: MessageEvent) => {
if (!isCms || event.data.message !== 'data:update') {
return;
}
const { fieldPath, value } = event.data.value;
if (fieldPath === path) {
setData(value);
}
},
[isCms, path],
) as EventListenerOrEventListenerObject;
useEffect(() => {
if (!isCms) {
return;
}
window?.addEventListener('message', onDataChange);
return () => {
window?.removeEventListener('message', onDataChange);
};
}, [isCms, onDataChange]);
return data ?? null;
}

View File

@ -8,8 +8,9 @@ import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraf
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import { invokeEvent } from '../registry';
import { fileForEntry } from '../util/collection.util';
import useDebouncedCallback from './useDebouncedCallback';
import DataUpdateEvent from '../util/events/DataEvent';
import { useWindowEvent } from '../util/window.util';
import useDebouncedCallback from './useDebouncedCallback';
import type { Collection, EntryData, Field } from '@staticcms/core/interface';
@ -41,7 +42,7 @@ async function handleChange(
window.dispatchEvent(
new DataUpdateEvent({
field: field.name,
fieldPath,
fieldPath: `${collection}.${fieldPath}`,
value: updatedValue,
}),
);
@ -63,19 +64,27 @@ async function handleChange(
}
interface EntryCallbackProps {
hasLivePreview: boolean;
collection: Collection;
slug: string | undefined;
callback: () => void;
}
export default function useEntryCallback({ slug, collection, callback }: EntryCallbackProps) {
export default function useEntryCallback({
hasLivePreview,
slug,
collection,
callback,
}: EntryCallbackProps) {
const dispatch = useAppDispatch();
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
const entry = useAppSelector(selectEditingDraft);
const [lastEntryData, setLastEntryData] = useState<EntryData>(cloneDeep(entry?.data));
const runUpdateCheck = useCallback(async () => {
if (isEqual(lastEntryData, entry?.data)) {
if ((hasLivePreview && !livePreviewLoaded) || isEqual(lastEntryData, entry?.data)) {
return;
}
@ -115,11 +124,17 @@ export default function useEntryCallback({ slug, collection, callback }: EntryCa
setLastEntryData(entry?.data);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry]);
}, [entry, livePreviewLoaded]);
const debouncedRunUpdateCheck = useDebouncedCallback(runUpdateCheck, 200);
useEffect(() => {
debouncedRunUpdateCheck();
}, [debouncedRunUpdateCheck]);
const handleLivePreviewLoaded = useCallback(() => {
setLivePreviewLoaded(true);
}, []);
useWindowEvent('livePreviewLoaded', handleLivePreviewLoaded);
}

View File

@ -0,0 +1,5 @@
export default class LivePreviewLoadedEvent extends CustomEvent<{}> {
constructor() {
super('livePreviewLoaded', {});
}
}

View File

@ -3,6 +3,7 @@ import { useEffect } from 'react';
import type AlertEvent from './events/AlertEvent';
import type ConfirmEvent from './events/ConfirmEvent';
import type DataUpdateEvent from './events/DataEvent';
import type LivePreviewLoadedEvent from './events/LivePreviewLoadedEvent';
import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent';
interface EventMap {
@ -10,6 +11,7 @@ interface EventMap {
confirm: ConfirmEvent;
mediaLibraryClose: MediaLibraryCloseEvent;
'data:update': DataUpdateEvent;
livePreviewLoaded: LivePreviewLoadedEvent;
}
export function useWindowEvent<K extends keyof WindowEventMap>(

View File

@ -1,6 +1,6 @@
import React from 'react';
import useData from '@staticcms/core/lib/hooks/useData';
import useData from './useData';
import type { ValueOrNestedValue } from '@staticcms/core/interface';
import type { FC } from 'react';

View File

@ -1 +1,2 @@
export { default as Data, type DataProps } from './Data';
export { default as useData } from './useData';

View File

@ -0,0 +1,48 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ValueOrNestedValue } from '@staticcms/core/interface';
export default function useData<I extends ValueOrNestedValue, O extends ValueOrNestedValue>(
value: O,
path?: string,
preprocessor?: (value: I) => O | Promise<O>,
) {
const [data, setData] = useState(value);
const isCms = useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get('useCmsData') === 'true';
}, []);
const onDataChange = useCallback(
async (event: MessageEvent) => {
if (!isCms || !path || event.data.message !== 'data:update') {
return;
}
const { fieldPath, value } = event.data.value;
if (fieldPath === path) {
setData(preprocessor ? await preprocessor(value) : value);
}
},
[isCms, path, preprocessor],
) as unknown as EventListenerOrEventListenerObject;
useEffect(() => {
if (!isCms || typeof window === 'undefined') {
return;
}
window.addEventListener('message', onDataChange);
return () => {
window.removeEventListener('message', onDataChange);
};
}, [isCms, onDataChange]);
return data ?? value;
}