kopia lustrzana https://github.com/wagtail/wagtail
Final clean up of PreviewController and usage
Move all bind() to connect() Add renderUrl value to use different URLs for sending vs rendering preview data Add docstrings for PreviewController properties Rename cleared property to ready and dispatch w-preview:ready Add events to PreviewController update lifecycle Use zero interval delay to disable preview auto update Rearrange PreviewController methods to better follow the update flow Ensure only one preview iframe has the w-preview-iframe ID at a given time This doesn't really affect how it functions, but it's semantically more correct Ensure runContentChecks function does not crash in tests Only clear stale data if there is a valid stale preview available Fix preview on Firefox by not removing the only iframe Reorder targets and values alphabetically and add JSDoc for events Introduce w-preview__proxy class for invisible elements in preview panelpull/12295/head
rodzic
054e72b4c2
commit
8d6d772da0
|
@ -101,13 +101,6 @@
|
|||
@include svg-icon(0.9rem);
|
||||
}
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__refresh-button.button--icon {
|
||||
|
@ -191,4 +184,15 @@
|
|||
&__mode-select {
|
||||
@apply w-outline-offset-inside;
|
||||
}
|
||||
|
||||
// A hidden element that is only rendered for functionality purposes,
|
||||
// but is not visible to the user. Used by radio inputs for preview sizes and
|
||||
// the iframe while it's loading. We nest the selector rather than suffixing
|
||||
// the parent selector to beat the specificity of input[type="radio"] styles
|
||||
.w-preview__proxy {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
|
|||
import { debounce } from '../utils/debounce';
|
||||
import { gettext } from '../utils/gettext';
|
||||
import type { ProgressController } from './ProgressController';
|
||||
import { setOptionalInterval } from '../utils/interval';
|
||||
|
||||
const runContentChecks = async () => {
|
||||
axe.registerPlugin(wagtailPreviewPlugin);
|
||||
|
@ -24,6 +25,11 @@ const runContentChecks = async () => {
|
|||
targetElement: 'main, [role="main"], body',
|
||||
});
|
||||
|
||||
// This requires Wagtail's preview plugin for axe to be registered in the
|
||||
// preview iframe, which is not done in tests as the registration happens via
|
||||
// the userbar.
|
||||
if (!contentMetrics) return;
|
||||
|
||||
renderContentMetrics({
|
||||
wordCount: contentMetrics.wordCount,
|
||||
readingTime: contentMetrics.readingTime,
|
||||
|
@ -89,65 +95,211 @@ const runAccessibilityChecks = async (
|
|||
);
|
||||
};
|
||||
|
||||
interface PreviewDataResponse {
|
||||
is_valid: boolean;
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls the preview panel component to submit the current form state and
|
||||
* update the preview iframe if the form is valid.
|
||||
*
|
||||
* Dispatches the following events in this order.
|
||||
*
|
||||
* @fires PreviewController#update - Before sending the preview data to the server. Cancelable.
|
||||
* @fires PreviewController#json - After the preview data update request is completed.
|
||||
* @fires PreviewController#error - When an error occurs while updating the preview data.
|
||||
* @fires PreviewController#load - Before reloading the preview iframe. Cancelable.
|
||||
* @fires PreviewController#loaded - After the preview iframe has been reloaded.
|
||||
* @fires PreviewController#ready - When the preview is ready for further updates – only fired on initial load.
|
||||
* @fires PreviewController#updated - After an update cycle is finished – may or may not involve reloading the iframe.
|
||||
*
|
||||
* @event PreviewController#update
|
||||
* @type {CustomEvent}
|
||||
* @property {boolean} cancelable - Is cancelable
|
||||
* @property {string} name - `w-preview:update`
|
||||
*
|
||||
* @event PreviewController#json
|
||||
* @type {CustomEvent}
|
||||
* @property {Object} detail
|
||||
* @property {PreviewDataResponse} detail.data - The response data that indicates whether the submitted data was valid and whether the preview is available.
|
||||
* @property {string} name - `w-preview:json`
|
||||
*
|
||||
* @event PreviewController#error
|
||||
* @type {CustomEvent}
|
||||
* @property {Object} detail
|
||||
* @property {Error} detail.error - The error object that was thrown.
|
||||
* @property {string} name - `w-preview:error`
|
||||
*
|
||||
* @event PreviewController#load
|
||||
* @type {CustomEvent}
|
||||
* @property {boolean} cancelable - Is cancelable
|
||||
* @property {string} name - `w-preview:load`
|
||||
*
|
||||
* @event PreviewController#loaded
|
||||
* @type {CustomEvent}
|
||||
* @property {string} name - `w-preview:loaded`
|
||||
*
|
||||
* @event PreviewController#ready
|
||||
* @type {CustomEvent}
|
||||
* @property {string} name - `w-preview:ready`
|
||||
*
|
||||
* @event PreviewController#updated
|
||||
* @type {CustomEvent}
|
||||
* @property {string} name - `w-preview:updated`
|
||||
*/
|
||||
export class PreviewController extends Controller<HTMLElement> {
|
||||
static classes = ['hasErrors', 'selectedSize'];
|
||||
static classes = ['hasErrors', 'proxy', 'selectedSize'];
|
||||
|
||||
static targets = ['size', 'newTab', 'spinner', 'mode', 'iframe'];
|
||||
static targets = ['iframe', 'mode', 'newTab', 'size', 'spinner'];
|
||||
|
||||
static values = {
|
||||
url: { default: '', type: String },
|
||||
autoUpdate: { default: true, type: Boolean },
|
||||
autoUpdateInterval: { default: 500, type: Number },
|
||||
deviceWidthProperty: { default: '--preview-device-width', type: String },
|
||||
panelWidthProperty: { default: '--preview-panel-width', type: String },
|
||||
deviceLocalStorageKey: {
|
||||
default: 'wagtail:preview-panel-device',
|
||||
type: String,
|
||||
},
|
||||
deviceWidthProperty: { default: '--preview-device-width', type: String },
|
||||
panelWidthProperty: { default: '--preview-panel-width', type: String },
|
||||
renderUrl: { default: '', type: String },
|
||||
url: { default: '', type: String },
|
||||
};
|
||||
|
||||
static outlets = ['w-progress'];
|
||||
|
||||
// Classes
|
||||
|
||||
/** CSS class to indicate that there are errors in the form. */
|
||||
declare readonly hasErrorsClass: string;
|
||||
/** CSS class for elements that are invisible and only rendered for functionality purposes. */
|
||||
declare readonly proxyClass: string;
|
||||
/** CSS class for the currently selected device size. */
|
||||
declare readonly selectedSizeClass: string;
|
||||
|
||||
declare readonly sizeTargets: HTMLInputElement[];
|
||||
declare readonly hasNewTabTarget: boolean;
|
||||
declare readonly newTabTarget: HTMLAnchorElement;
|
||||
declare readonly hasSpinnerTarget: boolean;
|
||||
declare readonly spinnerTarget: HTMLDivElement;
|
||||
declare readonly hasModeTarget: boolean;
|
||||
declare readonly modeTarget: HTMLSelectElement;
|
||||
declare readonly iframeTarget: HTMLIFrameElement;
|
||||
declare readonly iframeTargets: HTMLIFrameElement[];
|
||||
declare readonly urlValue: string;
|
||||
declare readonly autoUpdateValue: boolean;
|
||||
declare readonly autoUpdateIntervalValue: number;
|
||||
declare readonly deviceWidthPropertyValue: string;
|
||||
declare readonly panelWidthPropertyValue: string;
|
||||
declare readonly deviceLocalStorageKeyValue: string;
|
||||
// Targets
|
||||
|
||||
declare readonly hasWProgressOutlet: boolean;
|
||||
/** The main preview `<iframe>` that is currently displayed. */
|
||||
declare readonly iframeTarget: HTMLIFrameElement;
|
||||
/** All preview `<iframes>` that are currently in the DOM.
|
||||
* This contains the currently displayed `<iframe>` and may also contain
|
||||
* the new `<iframe>` that will replace the current one. */
|
||||
declare readonly iframeTargets: HTMLIFrameElement[];
|
||||
/** Preview mode `<select>` element. */
|
||||
declare readonly modeTarget: HTMLSelectElement;
|
||||
declare readonly hasModeTarget: boolean;
|
||||
/** New tab button. */
|
||||
declare readonly newTabTarget: HTMLAnchorElement;
|
||||
declare readonly hasNewTabTarget: boolean;
|
||||
/** Device size `<input type="radio">` elements. */
|
||||
declare readonly sizeTargets: HTMLInputElement[];
|
||||
/** Loading spinner. */
|
||||
declare readonly spinnerTarget: HTMLDivElement;
|
||||
declare readonly hasSpinnerTarget: boolean;
|
||||
|
||||
// Values
|
||||
|
||||
/** Interval in milliseconds when the form is checked for changes.
|
||||
* Also used as the debounce duration for the update request. */
|
||||
declare readonly autoUpdateIntervalValue: number;
|
||||
/** Key for storing the last selected device size in localStorage. */
|
||||
declare readonly deviceLocalStorageKeyValue: string;
|
||||
/** CSS property for setting the device width. */
|
||||
declare readonly deviceWidthPropertyValue: string;
|
||||
/** CSS property for the current width of the panel, to maintain the device scaling. */
|
||||
declare readonly panelWidthPropertyValue: string;
|
||||
/** URL for rendering the preview, defaults to `urlValue`.
|
||||
* Useful for headless setups where the front-end may be hosted at a different URL. */
|
||||
declare renderUrlValue: string;
|
||||
/** URL for updating the preview data. Also used for rendering the preview if `renderUrlValue` is unset. */
|
||||
declare readonly urlValue: string;
|
||||
|
||||
// Outlets
|
||||
|
||||
/** ProgressController for the refresh button that may be displayed when auto-update is turned off. */
|
||||
declare readonly wProgressOutlet: ProgressController;
|
||||
declare readonly hasWProgressOutlet: boolean;
|
||||
|
||||
// Instance variables with initial values set in connect()
|
||||
declare editForm: HTMLFormElement;
|
||||
declare sidePanelContainer: HTMLDivElement;
|
||||
|
||||
/** Side panel for content checks. */
|
||||
declare checksSidePanel: HTMLDivElement | null;
|
||||
/** Main editor form. */
|
||||
declare editForm: HTMLFormElement;
|
||||
/** ResizeObserver to observe when the panel is resized
|
||||
* so we can maintain the device size scaling. */
|
||||
declare resizeObserver: ResizeObserver;
|
||||
/** Side panel element of the preview panel, i.e. the element with the
|
||||
* `data-side-panel` attribute. Useful for listening to show/hide events.
|
||||
* Normally, this is the parent element of the controller element.
|
||||
*/
|
||||
declare sidePanelContainer: HTMLDivElement;
|
||||
|
||||
// Instance variables with initial values set here
|
||||
spinnerTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
updateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
cleared = false;
|
||||
|
||||
/** Whether the preview is ready for further updates.
|
||||
*
|
||||
* The preview data is stored in the session, which means:
|
||||
* - After logging out and logging back in, the session is cleared, so the
|
||||
* client must send the preview data on initial editor load in order for
|
||||
* Wagtail to render the preview.
|
||||
* - The preview data can persist after a full-page reload, as long as they
|
||||
* use the same key in the session.
|
||||
*
|
||||
* To ensure the preview data is available when the preview panel is opened,
|
||||
* we send an update request immediately. This can result in two scenarios:
|
||||
*
|
||||
* In edit views, the form is usually valid on initial load, as the object was
|
||||
* successfully saved before. In this case, we can go ahead with rendering the
|
||||
* preview and updating it with any new data.
|
||||
*
|
||||
* However, there may be cases where the form is invalid on initial load, e.g.
|
||||
* if the "expiry date" in the publishing schedule has become in the past.
|
||||
* Another common example is in create views, where the form is likely invalid
|
||||
* on initial load due to missing required fields (e.g. `title`).
|
||||
*
|
||||
* When this happens, Wagtail will not update the preview data in the session,
|
||||
* which means it may still contain the outdated preview data from the
|
||||
* previous full-page load. We want to clear this data immediately so that the
|
||||
* preview panel displays the "Preview is not available" screen instead of an
|
||||
* outdated preview.
|
||||
*
|
||||
* This flag determines whether the preview is "ready" for further updates –
|
||||
* i.e. this is true if the preview data has been cleared after an invalid
|
||||
* initial load, or if the preview data is already valid on initial load.
|
||||
*
|
||||
* An alternative approach would be to handle the initial state of the
|
||||
* session's preview data in the backend, but this would require the logic to
|
||||
* be applied in all the different places (i.e. page and snippets create and
|
||||
* edit views).
|
||||
*/
|
||||
ready = false;
|
||||
|
||||
/** Whether the preview is currently available. This is used to distinguish
|
||||
* whether we are rendering a preview or the "Preview is not available"
|
||||
* screen. So even if the preview is currently outdated, this is still `true`
|
||||
* as long as the preview data is available and the preview is rendered (e.g.
|
||||
* if the form becomes invalid after the preview is successfully rendered).
|
||||
*/
|
||||
available = true;
|
||||
updatePromise: Promise<boolean> | null = null;
|
||||
|
||||
/** Serialized form payload to be compared in between intervals to determine
|
||||
* whether an update should be performed. Note that we currently do not handle
|
||||
* file inputs.
|
||||
*/
|
||||
formPayload = '';
|
||||
|
||||
/** Timeout before displaying the loading spinner. */
|
||||
spinnerTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Interval for the auto-update. */
|
||||
updateInterval: ReturnType<typeof setOptionalInterval> = null;
|
||||
|
||||
/** Promise for the current update request. This is resolved as soon as the
|
||||
* update request is successful, so the preview iframe may not have been
|
||||
* fully reloaded.
|
||||
*/
|
||||
updatePromise: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* The default size input element.
|
||||
* This is the size input element with the `data-default-size` data attribute.
|
||||
|
@ -169,6 +321,188 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL of the preview iframe and the new tab button.
|
||||
* This takes into account the currently selected preview mode.
|
||||
*/
|
||||
get renderUrl(): URL {
|
||||
const url = new URL(this.renderUrlValue, window.location.href);
|
||||
if (this.hasModeTarget) {
|
||||
url.searchParams.set('mode', this.modeTarget.value);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.urlValue) {
|
||||
throw new Error(
|
||||
`The preview panel controller requires the data-${this.identifier}-url-value attribute to be set`,
|
||||
);
|
||||
}
|
||||
|
||||
this.resizeObserver = this.observePanelSize();
|
||||
|
||||
this.editForm = document.querySelector<HTMLFormElement>(
|
||||
'[data-edit-form]',
|
||||
) as HTMLFormElement;
|
||||
|
||||
// This controller is encapsulated as a child of the side panel element,
|
||||
// so we need to listen to the show/hide events on the parent element
|
||||
// (the one with [data-side-panel]).
|
||||
// If we had support for data-controller attribute on the side panels,
|
||||
// we could remove the intermediary element and make the [data-side-panel]
|
||||
// element to also act as the controller.
|
||||
this.sidePanelContainer = this.element.parentElement as HTMLDivElement;
|
||||
|
||||
this.checksSidePanel = document.querySelector('[data-side-panel="checks"]');
|
||||
|
||||
this.activatePreview = this.activatePreview.bind(this);
|
||||
this.deactivatePreview = this.deactivatePreview.bind(this);
|
||||
this.setPreviewData = this.setPreviewData.bind(this);
|
||||
this.checkAndUpdatePreview = this.checkAndUpdatePreview.bind(this);
|
||||
|
||||
this.sidePanelContainer.addEventListener('show', this.activatePreview);
|
||||
this.sidePanelContainer.addEventListener('hide', this.deactivatePreview);
|
||||
|
||||
this.checksSidePanel?.addEventListener('show', this.activatePreview);
|
||||
this.checksSidePanel?.addEventListener('hide', this.deactivatePreview);
|
||||
|
||||
this.restoreLastSavedPreferences();
|
||||
}
|
||||
|
||||
renderUrlValueChanged(newValue: string) {
|
||||
// Allow the rendering URL to be different from the URL used for sending the
|
||||
// preview data (e.g. for a headless setup), but make it optional and use
|
||||
// the latter as the default.
|
||||
if (!newValue) {
|
||||
this.renderUrlValue = this.urlValue;
|
||||
}
|
||||
this.updateNewTabLink();
|
||||
}
|
||||
|
||||
autoUpdateIntervalValueChanged() {
|
||||
// If the value is changed, only update the interval if it's currently active
|
||||
// as we don't want to start the interval when the panel is hidden
|
||||
if (this.updateInterval) this.addInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the last saved preferences.
|
||||
* Currently, only the last selected device size is restored.
|
||||
*/
|
||||
restoreLastSavedPreferences() {
|
||||
// Remember last selected device size
|
||||
let lastDevice: string | null = null;
|
||||
try {
|
||||
lastDevice = localStorage.getItem(this.deviceLocalStorageKeyValue);
|
||||
} catch (e) {
|
||||
// Initialise with the default device if the last one cannot be restored.
|
||||
}
|
||||
const lastDeviceInput =
|
||||
this.sizeTargets.find((input) => input.value === lastDevice) ||
|
||||
this.defaultSizeInput;
|
||||
lastDeviceInput.click();
|
||||
// If lastDeviceInput resolves to the defaultSizeInput, the click event will
|
||||
// not trigger the togglePreviewSize method, so we need to apply the
|
||||
// selected size class manually.
|
||||
this.applySelectedSizeClass(lastDeviceInput.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the preview mechanism.
|
||||
* The preview data is immediately updated. If auto-update is enabled,
|
||||
* an interval is set up to automatically check the form and update the
|
||||
* preview data.
|
||||
*/
|
||||
activatePreview() {
|
||||
// Immediately update the preview when the panel is opened
|
||||
this.checkAndUpdatePreview();
|
||||
|
||||
// Only set the interval while the panel is shown
|
||||
this.addInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the interval for auto-updating the preview and applies debouncing to
|
||||
* `setPreviewData` for subsequent calls.
|
||||
*/
|
||||
addInterval() {
|
||||
this.clearInterval();
|
||||
// This interval performs the checks for changes but not necessarily the
|
||||
// update itself
|
||||
this.updateInterval = setOptionalInterval(
|
||||
this.checkAndUpdatePreview,
|
||||
this.autoUpdateIntervalValue,
|
||||
);
|
||||
|
||||
if (this.updateInterval) {
|
||||
// Apply debounce for subsequent updates if not already applied
|
||||
if (!('cancel' in this.setPreviewData)) {
|
||||
this.setPreviewData = debounce(
|
||||
this.setPreviewData,
|
||||
this.autoUpdateIntervalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the auto-update interval.
|
||||
*/
|
||||
clearInterval() {
|
||||
if (!this.updateInterval) return;
|
||||
window.clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates the preview mechanism.
|
||||
*
|
||||
* If auto-update is enabled, clear the auto-update interval.
|
||||
*/
|
||||
deactivatePreview() {
|
||||
this.clearInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the new tab link with the currently selected preview mode,
|
||||
* then updates the preview.
|
||||
*/
|
||||
setPreviewMode() {
|
||||
this.updateNewTabLink();
|
||||
|
||||
// Make sure data is updated and an alert is displayed if an error occurs
|
||||
this.setPreviewDataWithAlert();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the URL of the new tab button with the currently selected preview mode.
|
||||
*/
|
||||
updateNewTabLink() {
|
||||
if (this.hasNewTabTarget) {
|
||||
this.newTabTarget.href = this.renderUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the preview size based on the selected input.
|
||||
* The selected device name (`input[value]`) is stored in localStorage.
|
||||
* @param event `InputEvent` from the size input
|
||||
*/
|
||||
togglePreviewSize(event: InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const device = target.value;
|
||||
const deviceWidth = target.dataset.deviceWidth;
|
||||
|
||||
this.setPreviewWidth(deviceWidth);
|
||||
this.applySelectedSizeClass(device);
|
||||
try {
|
||||
localStorage.setItem(this.deviceLocalStorageKeyValue, device);
|
||||
} catch (e) {
|
||||
// Skip saving the device if localStorage fails.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the simulated device width of the preview iframe.
|
||||
* @param width The width of the preview device. If falsy:
|
||||
|
@ -193,25 +527,6 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the preview size based on the selected input.
|
||||
* The selected device name (`input[value]`) is stored in localStorage.
|
||||
* @param event `InputEvent` from the size input
|
||||
*/
|
||||
togglePreviewSize(event: InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const device = target.value;
|
||||
const deviceWidth = target.dataset.deviceWidth;
|
||||
|
||||
this.setPreviewWidth(deviceWidth);
|
||||
this.applySelectedSizeClass(device);
|
||||
try {
|
||||
localStorage.setItem(this.deviceLocalStorageKeyValue, device);
|
||||
} catch (e) {
|
||||
// Skip saving the device if localStorage fails.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the selected size class to the specified device input's label, and
|
||||
* removes the class from all other device inputs' labels.
|
||||
|
@ -243,92 +558,99 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resets the preview panel state to be ready for the next update.
|
||||
* Like `setPreviewData`, but only updates the preview if there is no pending
|
||||
* update and the form has not changed.
|
||||
* @returns whether the data is valid
|
||||
*/
|
||||
finishUpdate() {
|
||||
if (this.spinnerTimeout) {
|
||||
clearTimeout(this.spinnerTimeout);
|
||||
this.spinnerTimeout = null;
|
||||
}
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTarget.hidden = true;
|
||||
}
|
||||
if (this.hasWProgressOutlet) {
|
||||
this.wProgressOutlet.loadingValue = false;
|
||||
}
|
||||
if (!this.cleared) {
|
||||
this.cleared = true;
|
||||
}
|
||||
this.updatePromise = null;
|
||||
|
||||
// Ensure the width is set to the default size if the preview is unavailable,
|
||||
// or the currently selected device size if the preview is available.
|
||||
this.setPreviewWidth();
|
||||
async checkAndUpdatePreview() {
|
||||
// Small performance optimisation: the hasChanges() method will not be called
|
||||
// if there is a pending update due to the || operator short-circuiting
|
||||
if (this.updatePromise || !this.hasChanges()) return undefined;
|
||||
return this.setPreviewData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the preview iframe.
|
||||
*
|
||||
* Instead of reloading the iframe with `iframe.contentWindow.location.reload()`
|
||||
* or updating the `src` attribute, this works by creating a new iframe that
|
||||
* replaces the old one once the new one has been loaded. This prevents the
|
||||
* iframe from flashing when reloading.
|
||||
* Checks whether the form data has changed since the last call to this method.
|
||||
* @returns whether the form data has changed
|
||||
*/
|
||||
reloadIframe() {
|
||||
// Copy the iframe element
|
||||
const newIframe = this.iframeTarget.cloneNode() as HTMLIFrameElement;
|
||||
hasChanges() {
|
||||
// https://github.com/microsoft/TypeScript/issues/30584
|
||||
const newPayload = new URLSearchParams(
|
||||
new FormData(this.editForm) as unknown as Record<string, string>,
|
||||
).toString();
|
||||
const changed = this.formPayload !== newPayload;
|
||||
|
||||
// The iframe does not have an src attribute on initial load,
|
||||
// so we need to set it here. For subsequent loads, it's fine to set it
|
||||
// again to ensure it's in sync with the selected preview mode.
|
||||
const url = new URL(this.urlValue, window.location.href);
|
||||
if (this.hasModeTarget) {
|
||||
url.searchParams.set('mode', this.modeTarget.value);
|
||||
}
|
||||
url.searchParams.set('in_preview_panel', 'true');
|
||||
newIframe.src = url.toString();
|
||||
|
||||
// Make the new iframe invisible
|
||||
newIframe.style.width = '0';
|
||||
newIframe.style.height = '0';
|
||||
newIframe.style.opacity = '0';
|
||||
newIframe.style.position = 'absolute';
|
||||
|
||||
// Put it in the DOM so it loads the page
|
||||
this.iframeTarget.insertAdjacentElement('afterend', newIframe);
|
||||
this.formPayload = newPayload;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the old iframe with the new iframe.
|
||||
* @param event The `load` event from the new iframe
|
||||
* Updates the preview data in the session. If the data is valid, the preview
|
||||
* iframe will be reloaded. If the data is invalid, the preview panel will
|
||||
* display an error message.
|
||||
* @returns whether the data is valid
|
||||
*/
|
||||
replaceIframe(event: Event) {
|
||||
const newIframe = event.target as HTMLIFrameElement;
|
||||
async setPreviewData() {
|
||||
// Bail out if there is already a pending update
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
|
||||
// Restore scroll position
|
||||
newIframe.contentWindow?.scroll(
|
||||
this.iframeTarget.contentWindow?.scrollX as number,
|
||||
this.iframeTarget.contentWindow?.scrollY as number,
|
||||
);
|
||||
const updateEvent = this.dispatch('update');
|
||||
if (updateEvent.defaultPrevented) return undefined;
|
||||
|
||||
// Remove the old iframe
|
||||
// This will disconnect the old iframe target, but it's fine because
|
||||
// the new iframe has been connected when we copy the attributes over,
|
||||
// thus subsequent references to this.iframeTarget will be the new iframe.
|
||||
// To verify, you can add console.log(this.iframeTargets) before and after
|
||||
// the following line and see that the array contains two and then one iframe.
|
||||
this.iframeTarget.remove();
|
||||
// Store the promise so that subsequent calls to setPreviewData will
|
||||
// return the same promise as long as it hasn't finished yet
|
||||
this.updatePromise = (async () => {
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTimeout = setTimeout(() => {
|
||||
this.spinnerTarget.hidden = false;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Make the new iframe visible
|
||||
newIframe.removeAttribute('style');
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.editForm),
|
||||
});
|
||||
const data: PreviewDataResponse = await response.json();
|
||||
|
||||
runContentChecks();
|
||||
this.dispatch('json', { cancelable: false, detail: { data } });
|
||||
|
||||
const onClickSelector = () => this.newTabTarget.click();
|
||||
runAccessibilityChecks(onClickSelector);
|
||||
this.element.classList.toggle(this.hasErrorsClass, !data.is_valid);
|
||||
this.available = data.is_available;
|
||||
|
||||
// Ready for another update
|
||||
this.finishUpdate();
|
||||
if (data.is_valid) {
|
||||
this.reloadIframe();
|
||||
} else if (!this.ready) {
|
||||
// This is the first update and the form data is not valid.
|
||||
|
||||
// If the preview contains stale valid data from the previous session
|
||||
// (hence available), we want to clear it immediately to show the
|
||||
// "Preview is not available" screen instead of the outdated preview.
|
||||
if (data.is_available) {
|
||||
this.updatePromise = this.clearPreviewData().then(() => false);
|
||||
} else {
|
||||
// There is no stale data, but we still need to load the iframe to
|
||||
// show the "Preview is not available" screen, because initially
|
||||
// the iframe is empty (to prevent loading the iframe when the panel
|
||||
// is never opened).
|
||||
this.reloadIframe();
|
||||
}
|
||||
} else {
|
||||
// Finish the process when the data is invalid to prepare for the next update
|
||||
// and avoid elements like the loading spinner to be shown indefinitely
|
||||
this.finishUpdate();
|
||||
}
|
||||
|
||||
return data.is_valid as boolean;
|
||||
} catch (error) {
|
||||
this.dispatch('error', { cancelable: false, detail: { error } });
|
||||
this.finishUpdate();
|
||||
// Re-throw error so it can be handled by setPreviewDataWithAlert
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.updatePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -349,65 +671,111 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Updates the preview data in the session. If the data is valid, the preview
|
||||
* iframe will be reloaded. If the data is invalid, the preview panel will
|
||||
* display an error message.
|
||||
* @returns whether the data is valid
|
||||
* Reloads the preview iframe.
|
||||
*
|
||||
* Instead of reloading the iframe with `iframe.contentWindow.location.reload()`
|
||||
* or updating the `src` attribute, this works by creating a new iframe that
|
||||
* replaces the old one once the new one has been loaded. This prevents the
|
||||
* iframe from flashing when reloading.
|
||||
*/
|
||||
async setPreviewData() {
|
||||
// Bail out if there is already a pending update
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
reloadIframe() {
|
||||
const loadEvent = this.dispatch('load');
|
||||
if (loadEvent.defaultPrevented) {
|
||||
// The load event is cancelled, so don't reload the iframe
|
||||
// and immediately finish the update
|
||||
this.finishUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the promise so that subsequent calls to setPreviewData will
|
||||
// return the same promise as long as it hasn't finished yet
|
||||
this.updatePromise = (async () => {
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTimeout = setTimeout(() => {
|
||||
this.spinnerTarget.hidden = false;
|
||||
}, 2000);
|
||||
}
|
||||
// Copy the iframe element
|
||||
const newIframe = this.iframeTarget.cloneNode() as HTMLIFrameElement;
|
||||
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
body: new FormData(this.editForm),
|
||||
});
|
||||
const data = await response.json();
|
||||
// Remove the ID to avoid duplicate IDs in the DOM
|
||||
newIframe.removeAttribute('id');
|
||||
|
||||
this.element.classList.toggle(this.hasErrorsClass, !data.is_valid);
|
||||
this.available = data.is_available;
|
||||
// The iframe does not have an src attribute on initial load,
|
||||
// so we need to set it here. For subsequent loads, it's fine to set it
|
||||
// again to ensure it's in sync with the selected preview mode.
|
||||
const url = this.renderUrl;
|
||||
url.searchParams.set('in_preview_panel', 'true');
|
||||
newIframe.src = url.toString();
|
||||
|
||||
if (data.is_valid) {
|
||||
this.reloadIframe();
|
||||
} else if (!this.cleared) {
|
||||
this.updatePromise = this.clearPreviewData().then(() => false);
|
||||
} else {
|
||||
// Finish the process when the data is invalid to prepare for the next update
|
||||
// and avoid elements like the loading spinner to be shown indefinitely
|
||||
this.finishUpdate();
|
||||
}
|
||||
// Make the new iframe invisible
|
||||
newIframe.classList.add(this.proxyClass);
|
||||
|
||||
return data.is_valid as boolean;
|
||||
} catch (error) {
|
||||
this.finishUpdate();
|
||||
// Re-throw error so it can be handled by setPreviewDataWithAlert
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.updatePromise;
|
||||
// Put it in the DOM so it loads the page
|
||||
this.iframeTarget.insertAdjacentElement('afterend', newIframe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `setPreviewData`, but only updates the preview if there is no pending
|
||||
* update and the form has not changed.
|
||||
* @returns whether the data is valid
|
||||
* Replaces the old iframe with the new iframe.
|
||||
* @param event The `load` event from the new iframe
|
||||
*/
|
||||
async checkAndUpdatePreview() {
|
||||
// Small performance optimisation: the hasChanges() method will not be called
|
||||
// if there is a pending update due to the || operator short-circuiting
|
||||
if (this.updatePromise || !this.hasChanges()) return undefined;
|
||||
return this.setPreviewData();
|
||||
replaceIframe(event: Event) {
|
||||
const id = this.iframeTarget.id;
|
||||
const newIframe = event.target as HTMLIFrameElement;
|
||||
|
||||
// On Firefox, the `load` event is also fired even when the iframe has no
|
||||
// `src` attribute, like in the initial render from the server template. Do
|
||||
// not run the replacement logic in this case.
|
||||
if (!newIframe.src) return;
|
||||
|
||||
// Restore scroll position
|
||||
newIframe.contentWindow?.scroll(
|
||||
this.iframeTarget.contentWindow?.scrollX as number,
|
||||
this.iframeTarget.contentWindow?.scrollY as number,
|
||||
);
|
||||
|
||||
// Remove any other existing iframes. Normally there are two iframes at this
|
||||
// point, the old one and the new one. However, the `load` event may be fired
|
||||
// more than once for the same iframe, e.g. if the `src` attribute is changed
|
||||
// – in which case there is only one iframe and that is also the new one.
|
||||
this.iframeTargets.forEach((iframe) => {
|
||||
if (iframe !== newIframe) {
|
||||
iframe.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Set the id and make the new iframe visible
|
||||
newIframe.id = id;
|
||||
newIframe.classList.remove(this.proxyClass);
|
||||
|
||||
this.dispatch('loaded', { cancelable: false });
|
||||
|
||||
runContentChecks();
|
||||
|
||||
const onClickSelector = () => this.newTabTarget.click();
|
||||
runAccessibilityChecks(onClickSelector);
|
||||
|
||||
// Ready for another update
|
||||
this.finishUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the preview panel state to be ready for the next update.
|
||||
*/
|
||||
finishUpdate() {
|
||||
if (this.spinnerTimeout) {
|
||||
clearTimeout(this.spinnerTimeout);
|
||||
this.spinnerTimeout = null;
|
||||
}
|
||||
if (this.hasSpinnerTarget) {
|
||||
this.spinnerTarget.hidden = true;
|
||||
}
|
||||
if (this.hasWProgressOutlet) {
|
||||
this.wProgressOutlet.loadingValue = false;
|
||||
}
|
||||
this.updatePromise = null;
|
||||
|
||||
// Ensure the width is set to the default size if the preview is unavailable,
|
||||
// or the currently selected device size if the preview is available.
|
||||
this.setPreviewWidth();
|
||||
|
||||
if (!this.ready) {
|
||||
this.ready = true;
|
||||
this.dispatch('ready', { cancelable: false });
|
||||
}
|
||||
this.dispatch('updated', { cancelable: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -449,121 +817,13 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
// Use the base URL value (without any params) as the target (identifier)
|
||||
// for the window, so that if the user switches between preview modes,
|
||||
// the same window will be reused.
|
||||
window.open(new URL(link.href).toString(), this.urlValue) as Window;
|
||||
const url = new URL(link.href);
|
||||
url.search = '';
|
||||
window.open(link.href, url.toString()) as Window;
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the form data has changed since the last call to this method.
|
||||
* @returns whether the form data has changed
|
||||
*/
|
||||
hasChanges() {
|
||||
// https://github.com/microsoft/TypeScript/issues/30584
|
||||
const newPayload = new URLSearchParams(
|
||||
new FormData(this.editForm) as unknown as Record<string, string>,
|
||||
).toString();
|
||||
const changed = this.formPayload !== newPayload;
|
||||
|
||||
this.formPayload = newPayload;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the preview mechanism.
|
||||
* The preview data is immediately updated. If auto-update is enabled,
|
||||
* debounce is applied to setPreviewData for subsequent calls, and an interval
|
||||
* is set up to automatically check the form and update the preview data.
|
||||
*/
|
||||
activatePreview() {
|
||||
// Immediately update the preview when the panel is opened
|
||||
this.checkAndUpdatePreview();
|
||||
|
||||
// Skip setting up the interval if auto update is disabled
|
||||
if (!this.autoUpdateValue) return;
|
||||
|
||||
// Apply debounce for subsequent updates if not already applied
|
||||
if (!('cancel' in this.setPreviewData)) {
|
||||
this.setPreviewData = debounce(
|
||||
this.setPreviewData.bind(this),
|
||||
this.autoUpdateIntervalValue,
|
||||
);
|
||||
}
|
||||
|
||||
// Only set the interval while the panel is shown
|
||||
// This interval performs the checks for changes but not necessarily the
|
||||
// update itself
|
||||
if (!this.updateInterval) {
|
||||
this.updateInterval = setInterval(
|
||||
this.checkAndUpdatePreview.bind(this),
|
||||
this.autoUpdateIntervalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates the preview mechanism.
|
||||
*
|
||||
* If auto-update is enabled, clear the auto-update interval.
|
||||
*/
|
||||
deactivatePreview() {
|
||||
if (!this.updateInterval) return;
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the preview mode in the iframe and new tab URLs,
|
||||
* then updates the preview.
|
||||
* @param event Event from the `<select>` element
|
||||
*/
|
||||
setPreviewMode(event: Event) {
|
||||
const mode = (event.target as HTMLSelectElement).value;
|
||||
const url = new URL(this.urlValue, window.location.href);
|
||||
|
||||
// Update the new tab link
|
||||
url.searchParams.set('mode', mode);
|
||||
this.newTabTarget.href = url.toString();
|
||||
|
||||
// Make sure data is updated and an alert is displayed if an error occurs
|
||||
this.setPreviewDataWithAlert();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (!this.urlValue) {
|
||||
throw new Error(
|
||||
`The preview panel controller requires the data-${this.identifier}-url-value attribute to be set`,
|
||||
);
|
||||
}
|
||||
|
||||
this.resizeObserver = this.observePanelSize();
|
||||
|
||||
this.editForm = document.querySelector<HTMLFormElement>(
|
||||
'[data-edit-form]',
|
||||
) as HTMLFormElement;
|
||||
|
||||
// This controller is encapsulated as a child of the side panel element,
|
||||
// so we need to listen to the show/hide events on the parent element
|
||||
// (the one with [data-side-panel]).
|
||||
// If we had support for data-controller attribute on the side panels,
|
||||
// we could remove the intermediary element and make the [data-side-panel]
|
||||
// element to also act as the controller.
|
||||
this.sidePanelContainer = this.element.parentElement as HTMLDivElement;
|
||||
|
||||
this.checksSidePanel = document.querySelector('[data-side-panel="checks"]');
|
||||
|
||||
this.activatePreview = this.activatePreview.bind(this);
|
||||
this.deactivatePreview = this.deactivatePreview.bind(this);
|
||||
|
||||
this.sidePanelContainer.addEventListener('show', this.activatePreview);
|
||||
this.sidePanelContainer.addEventListener('hide', this.deactivatePreview);
|
||||
|
||||
this.checksSidePanel?.addEventListener('show', this.activatePreview);
|
||||
this.checksSidePanel?.addEventListener('hide', this.deactivatePreview);
|
||||
|
||||
this.restoreLastSavedPreferences();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.sidePanelContainer.removeEventListener('show', this.activatePreview);
|
||||
this.sidePanelContainer.removeEventListener('hide', this.deactivatePreview);
|
||||
|
@ -573,26 +833,4 @@ export class PreviewController extends Controller<HTMLElement> {
|
|||
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the last saved preferences.
|
||||
* Currently, only the last selected device size is restored.
|
||||
*/
|
||||
restoreLastSavedPreferences() {
|
||||
// Remember last selected device size
|
||||
let lastDevice: string | null = null;
|
||||
try {
|
||||
lastDevice = localStorage.getItem(this.deviceLocalStorageKeyValue);
|
||||
} catch (e) {
|
||||
// Initialise with the default device if the last one cannot be restored.
|
||||
}
|
||||
const lastDeviceInput =
|
||||
this.sizeTargets.find((input) => input.value === lastDevice) ||
|
||||
this.defaultSizeInput;
|
||||
lastDeviceInput.click();
|
||||
// If lastDeviceInput resolves to the defaultSizeInput, the click event will
|
||||
// not trigger the togglePreviewSize method, so we need to apply the
|
||||
// selected size class manually.
|
||||
this.applySelectedSizeClass(lastDeviceInput.value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,28 @@
|
|||
{% load i18n wagtailadmin_tags %}
|
||||
|
||||
{% wagtail_config as settings %}
|
||||
{% load i18n l10n wagtailadmin_tags %}
|
||||
|
||||
<div
|
||||
class="w-preview"
|
||||
data-controller="w-preview"
|
||||
data-w-preview-has-errors-class="w-preview--has-errors"
|
||||
data-w-preview-proxy-class="w-preview__proxy"
|
||||
data-w-preview-selected-size-class="w-preview__size-button--selected"
|
||||
data-w-preview-auto-update-interval-value="{{ auto_update_interval|unlocalize }}"
|
||||
data-w-preview-url-value="{{ preview_url }}"
|
||||
data-w-preview-auto-update-value="{{ settings.WAGTAIL_AUTO_UPDATE_PREVIEW|yesno:'true,false' }}"
|
||||
data-w-preview-auto-update-interval-value="{{ settings.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL }}"
|
||||
data-w-preview-w-progress-outlet="[data-controller='w-preview'] [data-controller='w-progress']"
|
||||
>
|
||||
<div class="w-preview__sizes">
|
||||
{% block size_buttons %}
|
||||
<label class="w-preview__size-button">
|
||||
{% icon name="mobile-alt" %}
|
||||
<input type="radio" name="preview-size" value="mobile" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="375" data-default-size checked aria-label="{% trans 'Preview in mobile size' %}" />
|
||||
<input type="radio" class="w-preview__proxy" name="preview-size" value="mobile" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="375" data-default-size checked aria-label="{% trans 'Preview in mobile size' %}" />
|
||||
</label>
|
||||
<label class="w-preview__size-button">
|
||||
{% icon name="tablet-alt" %}
|
||||
<input type="radio" name="preview-size" value="tablet" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="768" aria-label="{% trans 'Preview in tablet size' %}" />
|
||||
<input type="radio" class="w-preview__proxy" name="preview-size" value="tablet" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="768" aria-label="{% trans 'Preview in tablet size' %}" />
|
||||
</label>
|
||||
<label class="w-preview__size-button">
|
||||
{% icon name="desktop" %}
|
||||
<input type="radio" name="preview-size" value="desktop" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="1280" aria-label="{% trans 'Preview in desktop size' %}" />
|
||||
<input type="radio" class="w-preview__proxy" name="preview-size" value="desktop" data-action="w-preview#togglePreviewSize" data-w-preview-target="size" data-device-width="1280" aria-label="{% trans 'Preview in desktop size' %}" />
|
||||
</label>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -33,7 +31,7 @@
|
|||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if settings.WAGTAIL_AUTO_UPDATE_PREVIEW %}
|
||||
{% if auto_update_interval %}
|
||||
<div class="w-preview__spinner" data-w-preview-target="spinner" hidden>
|
||||
{% icon name="spinner" classname="default" %}
|
||||
<span class="w-sr-only">{% trans 'Loading'%}</span>
|
||||
|
|
|
@ -940,18 +940,6 @@ def wagtail_config(context):
|
|||
"DISMISSIBLES": reverse("wagtailadmin_dismissibles"),
|
||||
},
|
||||
}
|
||||
|
||||
default_settings = {
|
||||
"WAGTAIL_AUTO_UPDATE_PREVIEW": True,
|
||||
"WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL": 500,
|
||||
}
|
||||
config.update(
|
||||
{
|
||||
option: getattr(settings, option, default)
|
||||
for option, default in default_settings.items()
|
||||
}
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.translation import gettext_lazy, ngettext
|
||||
|
@ -350,8 +351,15 @@ class PreviewSidePanel(BaseSidePanel):
|
|||
super().__init__(object, request)
|
||||
self.preview_url = preview_url
|
||||
|
||||
@property
|
||||
def auto_update_interval(self):
|
||||
if not getattr(settings, "WAGTAIL_AUTO_UPDATE_PREVIEW", True):
|
||||
return 0
|
||||
return getattr(settings, "WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL", 500)
|
||||
|
||||
def get_context_data(self, parent_context):
|
||||
context = super().get_context_data(parent_context)
|
||||
context["preview_url"] = self.preview_url
|
||||
context["has_multiple_modes"] = len(self.object.preview_modes) > 1
|
||||
context["auto_update_interval"] = self.auto_update_interval
|
||||
return context
|
||||
|
|
Ładowanie…
Reference in New Issue