feat: improved live preview support
This commit is contained in:
parent
5046dc1558
commit
f696d5c445
@ -21,7 +21,7 @@ import {
|
|||||||
import { loadMedia } from '@staticcms/core/actions/mediaLibrary';
|
import { loadMedia } from '@staticcms/core/actions/mediaLibrary';
|
||||||
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll';
|
import { loadScroll, toggleScroll } from '@staticcms/core/actions/scroll';
|
||||||
import useEntryCallback from '@staticcms/core/lib/hooks/useEntryCallback';
|
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 { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||||
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
|
import { selectEntry } from '@staticcms/core/reducers/selectors/entries';
|
||||||
@ -211,7 +211,24 @@ const Editor: FC<TranslatedProps<EditorProps>> = ({
|
|||||||
};
|
};
|
||||||
}, [collection, createBackup, entryDraft.entry, hasChanged]);
|
}, [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({
|
useEntryCallback({
|
||||||
|
hasLivePreview,
|
||||||
collection,
|
collection,
|
||||||
slug,
|
slug,
|
||||||
callback: () => {
|
callback: () => {
|
||||||
|
@ -8,6 +8,7 @@ import { EDITOR_SIZE_COMPACT } from '@staticcms/core/constants/views';
|
|||||||
import { getPreviewStyles, getPreviewTemplate } from '@staticcms/core/lib/registry';
|
import { getPreviewStyles, getPreviewTemplate } from '@staticcms/core/lib/registry';
|
||||||
import classNames from '@staticcms/core/lib/util/classNames.util';
|
import classNames from '@staticcms/core/lib/util/classNames.util';
|
||||||
import { selectTemplateName } from '@staticcms/core/lib/util/collection.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 { useWindowEvent } from '@staticcms/core/lib/util/window.util';
|
||||||
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
|
||||||
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
|
||||||
@ -195,6 +196,10 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
|||||||
|
|
||||||
useWindowEvent('data:update', passEventToIframe);
|
useWindowEvent('data:update', passEventToIframe);
|
||||||
|
|
||||||
|
const handleLivePreviewIframeLoaded = useCallback(() => {
|
||||||
|
window.dispatchEvent(new LivePreviewLoadedEvent());
|
||||||
|
}, []);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return null;
|
return null;
|
||||||
@ -222,9 +227,11 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
|||||||
<ErrorBoundary config={config}>
|
<ErrorBoundary config={config}>
|
||||||
{livePreviewUrlTemplate ? (
|
{livePreviewUrlTemplate ? (
|
||||||
<iframe
|
<iframe
|
||||||
|
key="live-preview-frame"
|
||||||
ref={livePreviewIframe}
|
ref={livePreviewIframe}
|
||||||
src={`${livePreviewUrlTemplate}?useCmsData=true`}
|
src={`${livePreviewUrlTemplate}?useCmsData=true`}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
onLoad={handleLivePreviewIframeLoaded}
|
||||||
/>
|
/>
|
||||||
) : previewInFrame ? (
|
) : previewInFrame ? (
|
||||||
<Frame
|
<Frame
|
||||||
@ -280,6 +287,7 @@ const EditorPreviewPane = (props: TranslatedProps<EditorPreviewPaneProps>) => {
|
|||||||
config,
|
config,
|
||||||
editorSize,
|
editorSize,
|
||||||
element,
|
element,
|
||||||
|
handleLivePreviewIframeLoaded,
|
||||||
initialFrameContent,
|
initialFrameContent,
|
||||||
livePreviewUrlTemplate,
|
livePreviewUrlTemplate,
|
||||||
previewComponent,
|
previewComponent,
|
||||||
|
@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import { currentBackend } from './backend';
|
import { currentBackend } from './backend';
|
||||||
import bootstrap from './bootstrap';
|
import bootstrap from './bootstrap';
|
||||||
import useData from './lib/hooks/useData';
|
|
||||||
import useEntries from './lib/hooks/useEntries';
|
import useEntries from './lib/hooks/useEntries';
|
||||||
import useFolderSupport from './lib/hooks/useFolderSupport';
|
import useFolderSupport from './lib/hooks/useFolderSupport';
|
||||||
import useHasChildErrors from './lib/hooks/useHasChildErrors';
|
import useHasChildErrors from './lib/hooks/useHasChildErrors';
|
||||||
@ -13,6 +12,7 @@ import useMediaFiles from './lib/hooks/useMediaFiles';
|
|||||||
import useMediaInsert from './lib/hooks/useMediaInsert';
|
import useMediaInsert from './lib/hooks/useMediaInsert';
|
||||||
import useUUID from './lib/hooks/useUUID';
|
import useUUID from './lib/hooks/useUUID';
|
||||||
import Registry from './lib/registry';
|
import Registry from './lib/registry';
|
||||||
|
import useData from './live/useData';
|
||||||
|
|
||||||
export * from './backends';
|
export * from './backends';
|
||||||
export * from './interface';
|
export * from './interface';
|
||||||
|
@ -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 useEntries } from './useEntries';
|
||||||
export { default as useFolderSupport } from './useFolderSupport';
|
export { default as useFolderSupport } from './useFolderSupport';
|
||||||
export { default as useHasChildErrors } from './useHasChildErrors';
|
export { default as useHasChildErrors } from './useHasChildErrors';
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -8,8 +8,9 @@ import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraf
|
|||||||
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
|
||||||
import { invokeEvent } from '../registry';
|
import { invokeEvent } from '../registry';
|
||||||
import { fileForEntry } from '../util/collection.util';
|
import { fileForEntry } from '../util/collection.util';
|
||||||
import useDebouncedCallback from './useDebouncedCallback';
|
|
||||||
import DataUpdateEvent from '../util/events/DataEvent';
|
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';
|
import type { Collection, EntryData, Field } from '@staticcms/core/interface';
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ async function handleChange(
|
|||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new DataUpdateEvent({
|
new DataUpdateEvent({
|
||||||
field: field.name,
|
field: field.name,
|
||||||
fieldPath,
|
fieldPath: `${collection}.${fieldPath}`,
|
||||||
value: updatedValue,
|
value: updatedValue,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -63,19 +64,27 @@ async function handleChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EntryCallbackProps {
|
interface EntryCallbackProps {
|
||||||
|
hasLivePreview: boolean;
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
slug: string | undefined;
|
slug: string | undefined;
|
||||||
callback: () => void;
|
callback: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useEntryCallback({ slug, collection, callback }: EntryCallbackProps) {
|
export default function useEntryCallback({
|
||||||
|
hasLivePreview,
|
||||||
|
slug,
|
||||||
|
collection,
|
||||||
|
callback,
|
||||||
|
}: EntryCallbackProps) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
||||||
|
|
||||||
const entry = useAppSelector(selectEditingDraft);
|
const entry = useAppSelector(selectEditingDraft);
|
||||||
const [lastEntryData, setLastEntryData] = useState<EntryData>(cloneDeep(entry?.data));
|
const [lastEntryData, setLastEntryData] = useState<EntryData>(cloneDeep(entry?.data));
|
||||||
|
|
||||||
const runUpdateCheck = useCallback(async () => {
|
const runUpdateCheck = useCallback(async () => {
|
||||||
if (isEqual(lastEntryData, entry?.data)) {
|
if ((hasLivePreview && !livePreviewLoaded) || isEqual(lastEntryData, entry?.data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,11 +124,17 @@ export default function useEntryCallback({ slug, collection, callback }: EntryCa
|
|||||||
setLastEntryData(entry?.data);
|
setLastEntryData(entry?.data);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [entry]);
|
}, [entry, livePreviewLoaded]);
|
||||||
|
|
||||||
const debouncedRunUpdateCheck = useDebouncedCallback(runUpdateCheck, 200);
|
const debouncedRunUpdateCheck = useDebouncedCallback(runUpdateCheck, 200);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedRunUpdateCheck();
|
debouncedRunUpdateCheck();
|
||||||
}, [debouncedRunUpdateCheck]);
|
}, [debouncedRunUpdateCheck]);
|
||||||
|
|
||||||
|
const handleLivePreviewLoaded = useCallback(() => {
|
||||||
|
setLivePreviewLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useWindowEvent('livePreviewLoaded', handleLivePreviewLoaded);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
export default class LivePreviewLoadedEvent extends CustomEvent<{}> {
|
||||||
|
constructor() {
|
||||||
|
super('livePreviewLoaded', {});
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { useEffect } from 'react';
|
|||||||
import type AlertEvent from './events/AlertEvent';
|
import type AlertEvent from './events/AlertEvent';
|
||||||
import type ConfirmEvent from './events/ConfirmEvent';
|
import type ConfirmEvent from './events/ConfirmEvent';
|
||||||
import type DataUpdateEvent from './events/DataEvent';
|
import type DataUpdateEvent from './events/DataEvent';
|
||||||
|
import type LivePreviewLoadedEvent from './events/LivePreviewLoadedEvent';
|
||||||
import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent';
|
import type MediaLibraryCloseEvent from './events/MediaLibraryCloseEvent';
|
||||||
|
|
||||||
interface EventMap {
|
interface EventMap {
|
||||||
@ -10,6 +11,7 @@ interface EventMap {
|
|||||||
confirm: ConfirmEvent;
|
confirm: ConfirmEvent;
|
||||||
mediaLibraryClose: MediaLibraryCloseEvent;
|
mediaLibraryClose: MediaLibraryCloseEvent;
|
||||||
'data:update': DataUpdateEvent;
|
'data:update': DataUpdateEvent;
|
||||||
|
livePreviewLoaded: LivePreviewLoadedEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWindowEvent<K extends keyof WindowEventMap>(
|
export function useWindowEvent<K extends keyof WindowEventMap>(
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
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 { ValueOrNestedValue } from '@staticcms/core/interface';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
@ -1 +1,2 @@
|
|||||||
export { default as Data, type DataProps } from './Data';
|
export { default as Data, type DataProps } from './Data';
|
||||||
|
export { default as useData } from './useData';
|
||||||
|
48
packages/core/src/live/useData.tsx
Normal file
48
packages/core/src/live/useData.tsx
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user