Initial migration of preview panel to Stimulus PreviewController

Initialise preview panel as a Stimulus PreviewController

Fix TypeScript issues in PreviewController

Use Stimulus targets for preview device size inputs

Use Stimulus target for preview panel new tab button

Use Stimulus target for preview panel loading spinner

Use Stimulus target for preview panel refresh button

Use Stimulus target for preview panel mode select

Use Stimulus target for preview panel iframe

Also rename the id to w-preview-iframe to follow our new conventions for
singleton elements

Rely on Stimulus to replace the iframe target

Because we copy all the attributes from the old iframe to the new one,
this means that the new iframe will also become a target. When we remove
the old iframe, the target is disconnected, and subsequent references to
this.iframeTarget should point to the new one.

Access the preview panel's parent side panel element via this.element.parentElement

Bind preview device size inputs using Stimulus data-action

Extract PreviewController.observePanelSize() method

Extract PreviewController.restoreLastSavedPreferences() method

Use Stimulus value for preview panel url

Use Stimulus value for preview panel pending update state

Move edit form and spinner timeout references into instance variables

Extract PreviewController.finishUpdate() method

Extract PreviewController.reloadIframe() method

Extract PreviewController.clearPreviewData() and setPreviewData() methods

Replace preview panel refresh button target with data-action

Extract openPreviewInNewTab method and use it via data-action

Do not close the preview tab if the data is not valid

Use Stimulus values for preview panel auto update config

Extract PreviewController.initAutoUpdate() method

Extract handlePreviewModeChange method and use it via data-action

Use Stimulus classes for preview panel unavailable and has-errors CSS classes

Use Stimulus class for preview panel selected input size

This removes the reliance of having a predefined set of classes for each
device name, making it easier to add support for custom sizes later.

The outline styles have also been updated to make use of focus-visible
when available.

Use hidden attribute for hiding preview panel spinner

Replace PreviewController isUpdating value with an updatePromise instance variable

Extract PreviewController.hasChanges() method

Extract PreviewController.checkAndUpdatePreview() method

Add default values for PreviewController values

Use ProgressController outlet for preview panel refresh button

This allows the use of the button-longrunning handling for the loading state.

Also, turn the button into an icon-only button as there might not be enough space when the panel is resized to the smallest size

Use cloneNode() instead of manually copying the attributes

Extract PreviewController.replaceIframe() and use it as the iframe's action

Extract PreviewController.sidePanelContainer instance attribute

Extract PreviewController.checksSidePanel instance attribute

Extract PreviewController.updateInterval instance attribute

Clean up PreviewController event listeners on disconnect

Extract preview panel device localStorage key into PreviewController Stimulus value

Extract preview panel's width CSS property names into Stimulus values

Disconnect preview panel ResizeObserver on controller disconnect

Use an instance variable for tracking preview availability
pull/12295/head
Sage Abdullah 2023-03-23 16:52:42 +00:00 zatwierdzone przez LB (Ben Johnston)
rodzic 5aa0dde2a4
commit 55d57be7c5
8 zmienionych plików z 572 dodań i 330 usunięć

Wyświetl plik

@ -73,10 +73,22 @@
place-items: center;
cursor: pointer;
&:focus-within {
&--selected,
&--selected:hover {
@apply w-bg-surface-menus w-text-text-button w-transform-none w-border w-border-transparent;
}
&:focus-within:has(:focus-visible) {
@include focus-outline;
}
@supports not selector(:focus-visible) {
&:focus-within {
/* Fallback for browsers without :focus-visible support */
@include focus-outline;
}
}
.icon {
@include svg-icon(1rem);
@ -101,13 +113,14 @@
&__refresh-button.button--icon {
display: flex;
align-items: center;
gap: 0.5rem;
position: absolute;
top: 1.25rem;
inset-inline-end: 1.5rem;
.icon {
@include svg-icon(0.9rem);
// Use element and class selectors to beat .button-longrunning specificity
svg.icon,
svg.icon-spinner {
margin-inline-end: 0;
}
}
@ -117,12 +130,6 @@
inset-inline-end: 1.5rem;
}
&--mobile &__size-button--mobile,
&--tablet &__size-button--tablet,
&--desktop &__size-button--desktop {
@apply w-bg-surface-menus w-text-text-button w-transform-none w-border w-border-transparent;
}
&__controls {
@apply w-border-t w-border-transparent w-duration-500 w-ease-in-out;
transition-property: border-color, margin-top, padding-top;

Wyświetl plik

@ -0,0 +1,28 @@
import { Application } from '@hotwired/stimulus';
import { PreviewController } from './PreviewController';
describe('PreviewController', () => {
let application;
const url = '/preview/';
beforeEach(() => {
document.body.innerHTML = `
<div
class="preview-panel preview-panel--mobile"
data-controller="w-preview"
data-action="${url}"
>
</div>
`;
});
afterEach(() => {
application.stop();
});
it('should start the application', async () => {
// start application
application = Application.start();
application.register('w-preview', PreviewController);
});
});

Wyświetl plik

@ -1,18 +1,21 @@
import axe from 'axe-core';
import { Controller } from '@hotwired/stimulus';
import type { ContextObject } from 'axe-core';
import {
getAxeConfiguration,
getA11yReport,
renderA11yResults,
} from '../../includes/a11y-result';
import { wagtailPreviewPlugin } from '../../includes/previewPlugin';
} from '../includes/a11y-result';
import { wagtailPreviewPlugin } from '../includes/previewPlugin';
import {
getPreviewContentMetrics,
renderContentMetrics,
} from '../../includes/contentMetrics';
import { WAGTAIL_CONFIG } from '../../config/wagtailConfig';
import { debounce } from '../../utils/debounce';
import { gettext } from '../../utils/gettext';
} from '../includes/contentMetrics';
import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
import { debounce } from '../utils/debounce';
import { gettext } from '../utils/gettext';
import type { ProgressController } from './ProgressController';
const runContentChecks = async () => {
axe.registerPlugin(wagtailPreviewPlugin);
@ -27,31 +30,47 @@ const runContentChecks = async () => {
});
};
const runAccessibilityChecks = async (onClickSelector) => {
const a11yRowTemplate = document.querySelector('#w-a11y-result-row-template');
const checksPanel = document.querySelector('[data-checks-panel]');
const runAccessibilityChecks = async (
onClickSelector: (selectorName: string, event: MouseEvent) => void,
) => {
const a11yRowTemplate = document.querySelector<HTMLTemplateElement>(
'#w-a11y-result-row-template',
);
const checksPanel = document.querySelector<HTMLElement>(
'[data-checks-panel]',
);
const config = getAxeConfiguration(document.body);
const toggleCounter = document.querySelector(
const toggleCounter = document.querySelector<HTMLElement>(
'[data-side-panel-toggle="checks"] [data-side-panel-toggle-counter]',
);
const panelCounter = document.querySelector(
const panelCounter = document.querySelector<HTMLElement>(
'[data-side-panel="checks"] [data-a11y-result-count]',
);
if (!a11yRowTemplate || !config || !toggleCounter || !panelCounter) {
if (
!checksPanel ||
!a11yRowTemplate ||
!config ||
!toggleCounter ||
!panelCounter
) {
return;
}
// Ensure we only test within the preview iframe, but nonetheless with the correct selectors.
config.context = {
include: {
fromFrames: ['#preview-iframe'].concat(config.context.include),
fromFrames: ['#w-preview-iframe'].concat(
(config.context as ContextObject).include as string[],
),
},
};
if (config.context.exclude?.length > 0) {
} as ContextObject;
if ((config.context.exclude as string[])?.length > 0) {
config.context.exclude = {
fromFrames: ['#preview-iframe'].concat(config.context.exclude),
};
fromFrames: ['#w-preview-iframe'].concat(
config.context.exclude as string[],
),
} as ContextObject['exclude'];
}
const { results, a11yErrorsNumber } = await getA11yReport(config);
@ -70,339 +89,498 @@ const runAccessibilityChecks = async (onClickSelector) => {
);
};
function initPreview() {
const previewSidePanel = document.querySelector(
'[data-side-panel="preview"]',
);
const checksSidePanel = document.querySelector('[data-side-panel="checks"]');
/**
* Controls the preview panel component to submit the current form state and
* update the preview iframe if the form is valid.
*/
export class PreviewController extends Controller<HTMLElement> {
static classes = ['hasErrors', 'selectedSize'];
// Preview side panel is not shown if the object does not have any preview modes
if (!previewSidePanel) return;
static targets = ['size', 'newTab', 'spinner', 'mode', 'iframe'];
// The previewSidePanel is a generic container for side panels,
// the content of the preview panel itself is in a child element
const previewPanel = previewSidePanel.querySelector('[data-preview-panel]');
//
// Preview size handling
//
const sizeInputs = previewPanel.querySelectorAll('[data-device-width]');
const defaultSizeInput = previewPanel.querySelector('[data-default-size]');
const setPreviewWidth = (width) => {
const isUnavailable = previewPanel.classList.contains(
'preview-panel--unavailable',
);
let deviceWidth = width;
// Reset to default size if width is falsy or preview is unavailable
if (!width || isUnavailable) {
deviceWidth = defaultSizeInput.dataset.deviceWidth;
}
previewPanel.style.setProperty('--preview-device-width', deviceWidth);
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,
},
};
const togglePreviewSize = (event) => {
const device = event.target.value;
const deviceWidth = event.target.dataset.deviceWidth;
static outlets = ['w-progress'];
setPreviewWidth(deviceWidth);
declare readonly hasErrorsClass: string;
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;
declare readonly hasWProgressOutlet: boolean;
declare readonly wProgressOutlet: ProgressController;
// Instance variables with initial values set in connect()
declare editForm: HTMLFormElement;
declare sidePanelContainer: HTMLDivElement;
declare checksSidePanel: HTMLDivElement | null;
declare resizeObserver: ResizeObserver;
// Instance variables with initial values set here
spinnerTimeout: ReturnType<typeof setTimeout> | null = null;
updateInterval: ReturnType<typeof setInterval> | null = null;
cleared = false;
available = true;
updatePromise: Promise<boolean> | null = null;
formPayload = '';
/**
* The default size input element.
* This is the size input element with the `data-default-size` data attribute.
* If no input element has this attribute, the first size input element will be used.
*/
get defaultSizeInput(): HTMLInputElement {
return (
this.sizeTargets.find((input) => 'defaultSize' in input.dataset) ||
this.sizeTargets[0]
);
}
/**
* The currently active device size input element. Falls back to the default size input.
*/
get activeSizeInput(): HTMLInputElement {
return (
this.sizeTargets.find((input) => input.checked) || this.defaultSizeInput
);
}
/**
* Sets the simulated device width of the preview iframe.
* @param width The width of the preview device. If falsy:
* - the default size will be used if the preview is currently unavailable,
* - otherwise, the currently selected device size is used.
*/
setPreviewWidth(width?: string) {
let deviceWidth = width;
if (!width) {
// Restore width using the currently active device size input
deviceWidth = this.activeSizeInput.dataset.deviceWidth;
}
if (!this.available) {
// Ensure the 'Preview not available' message is not scaled down
deviceWidth = this.defaultSizeInput.dataset.deviceWidth;
}
this.element.style.setProperty(
this.deviceWidthPropertyValue,
deviceWidth as string,
);
}
/**
* 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);
try {
localStorage.setItem('wagtail:preview-panel-device', device);
localStorage.setItem(this.deviceLocalStorageKeyValue, device);
} catch (e) {
// Skip saving the device if localStorage fails.
}
// Ensure only one device class is applied
sizeInputs.forEach((input) => {
previewPanel.classList.toggle(
`preview-panel--${input.value}`,
input.value === device,
// Ensure only one selected class is applied
this.sizeTargets.forEach((input) => {
// The <input> is invisible and we're using a <label> parent to style it.
input.labels?.forEach((label) =>
label.classList.toggle(this.selectedSizeClass, input.value === device),
);
});
};
}
sizeInputs.forEach((input) =>
input.addEventListener('change', togglePreviewSize),
);
/**
* Observes the preview panel size and set the `--preview-panel-width` CSS variable.
* This is used to maintain the simulated device width as the side panel is resized.
*/
observePanelSize() {
const resizeObserver = new ResizeObserver((entries) =>
this.element.style.setProperty(
this.panelWidthPropertyValue,
entries[0].contentRect.width.toString(),
),
);
resizeObserver.observe(this.element);
return resizeObserver;
}
const resizeObserver = new ResizeObserver((entries) =>
previewPanel.style.setProperty(
'--preview-panel-width',
entries[0].contentRect.width,
),
);
resizeObserver.observe(previewPanel);
/**
* 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;
}
if (!this.cleared) {
this.cleared = true;
}
this.updatePromise = null;
//
// Preview data handling
//
// In order to make the preview truly reliable, the preview page needs
// to be perfectly independent from the edit page,
// from the browser perspective. To pass data from the edit page
// to the preview page, we send the form after each change
// and save it inside the user session.
// 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();
}
const newTabButton = previewPanel.querySelector('[data-preview-new-tab]');
const refreshButton = previewPanel.querySelector('[data-refresh-preview]');
const loadingSpinner = previewPanel.querySelector('[data-preview-spinner]');
const form = document.querySelector('[data-edit-form]');
const previewUrl = previewPanel.dataset.action;
const previewModeSelect = document.querySelector(
'[data-preview-mode-select]',
);
let iframe = previewPanel.querySelector('[data-preview-iframe]');
let spinnerTimeout;
let hasPendingUpdate = false;
let cleared = false;
/**
* 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.
*/
reloadIframe() {
// Copy the iframe element
const newIframe = this.iframeTarget.cloneNode() as HTMLIFrameElement;
const finishUpdate = () => {
clearTimeout(spinnerTimeout);
loadingSpinner.classList.add('w-hidden');
hasPendingUpdate = false;
};
const reloadIframe = () => {
// Instead of reloading the iframe, we're replacing it with a new iframe to
// prevent flashing
// Create a new invisible iframe element
const newIframe = document.createElement('iframe');
const url = new URL(previewUrl, window.location.href);
if (previewModeSelect) {
url.searchParams.set('mode', previewModeSelect.value);
// 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.style.width = 0;
newIframe.style.height = 0;
newIframe.style.opacity = 0;
newIframe.style.position = 'absolute';
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
iframe.insertAdjacentElement('afterend', newIframe);
this.iframeTarget.insertAdjacentElement('afterend', newIframe);
}
const handleLoad = () => {
// Copy all attributes from the old iframe to the new one,
// except src as that will cause the iframe to be reloaded
Array.from(iframe.attributes).forEach((key) => {
if (key.nodeName === 'src') return;
newIframe.setAttribute(key.nodeName, key.nodeValue);
});
/**
* Replaces the old iframe with the new iframe.
* @param event The `load` event from the new iframe
*/
replaceIframe(event: Event) {
const newIframe = event.target as HTMLIFrameElement;
// Restore scroll position
newIframe.contentWindow.scroll(
iframe.contentWindow.scrollX,
iframe.contentWindow.scrollY,
);
// Remove the old iframe and swap it with the new one
iframe.remove();
iframe = newIframe;
// Make the new iframe visible
newIframe.style = null;
// Ready for another update
finishUpdate();
// Remove the load event listener so it doesn't fire when switching modes
newIframe.removeEventListener('load', handleLoad);
runContentChecks();
const onClickSelector = () => newTabButton.click();
runAccessibilityChecks(onClickSelector);
};
newIframe.addEventListener('load', handleLoad);
};
const clearPreviewData = () =>
fetch(previewUrl, {
headers: { [WAGTAIL_CONFIG.CSRF_HEADER_NAME]: WAGTAIL_CONFIG.CSRF_TOKEN },
method: 'DELETE',
});
const setPreviewData = () => {
// Bail out if there is already a pending update
if (hasPendingUpdate) return Promise.resolve();
hasPendingUpdate = true;
spinnerTimeout = setTimeout(
() => loadingSpinner.classList.remove('w-hidden'),
2000,
// Restore scroll position
newIframe.contentWindow?.scroll(
this.iframeTarget.contentWindow?.scrollX as number,
this.iframeTarget.contentWindow?.scrollY as number,
);
return fetch(previewUrl, {
method: 'POST',
body: new FormData(form),
})
.then((response) => response.json())
.then((data) => {
previewPanel.classList.toggle(
'preview-panel--has-errors',
!data.is_valid,
);
previewPanel.classList.toggle(
'preview-panel--unavailable',
!data.is_available,
);
// 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();
if (!data.is_available) {
// Ensure the 'Preview not available' message is not scaled down
setPreviewWidth();
}
// Make the new iframe visible
newIframe.removeAttribute('style');
runContentChecks();
const onClickSelector = () => this.newTabTarget.click();
runAccessibilityChecks(onClickSelector);
// Ready for another update
this.finishUpdate();
}
/**
* Clears the preview data from the session.
* @returns `Response` from the fetch `DELETE` request
*/
async clearPreviewData() {
return fetch(this.urlValue, {
headers: {
[WAGTAIL_CONFIG.CSRF_HEADER_NAME]: WAGTAIL_CONFIG.CSRF_TOKEN,
},
method: 'DELETE',
}).then((response) => {
this.available = false;
this.reloadIframe();
return response;
});
}
/**
* 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
*/
async setPreviewData() {
// Bail out if there is already a pending update
if (this.updatePromise) return this.updatePromise;
// 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);
}
try {
const response = await fetch(this.urlValue, {
method: 'POST',
body: new FormData(this.editForm),
});
const data = await response.json();
this.element.classList.toggle(this.hasErrorsClass, !data.is_valid);
this.available = data.is_available;
if (data.is_valid) {
reloadIframe();
} else if (!cleared) {
clearPreviewData();
cleared = true;
reloadIframe();
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
finishUpdate();
this.finishUpdate();
}
return data.is_valid;
})
.catch((error) => {
finishUpdate();
// Re-throw error so it can be handled by handlePreview
return data.is_valid as boolean;
} catch (error) {
this.finishUpdate();
// Re-throw error so it can be handled by setPreviewDataWithAlert
throw error;
});
};
}
})();
const handlePreview = () =>
setPreviewData().catch(() => {
return this.updatePromise;
}
/**
* 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
*/
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();
}
/**
* Like `setPreviewData`, but also displays an alert if an error occurred while
* updating the preview data. Note that this will not display an alert if the
* update request was successful, but the data is invalid.
*
* This is useful when the preview data is updated in response to a user
* interaction, such as:
* - clicking the "open in new tab" link
* - clicking the "Refresh" button (if auto update is disabled)
* - changing the preview mode.
* @returns whether the data is valid
*/
async setPreviewDataWithAlert() {
try {
return await this.setPreviewData();
} catch {
// eslint-disable-next-line no-alert
window.alert(gettext('Error while sending preview data.'));
});
// we don't know if the data is valid or not as the request failed
return undefined;
}
}
const handlePreviewInNewTab = (event) => {
/**
* Like `setPreviewDataWithAlert`, but also opens the preview in a new tab.
* If an existing tab for the preview is already open, it will be focused and
* reloaded.
* @param event The click event
* @returns whether the data is valid
*/
async openPreviewInNewTab(event: MouseEvent) {
event.preventDefault();
const previewWindow = window.open('', previewUrl);
previewWindow.focus();
const link = event.currentTarget as HTMLAnchorElement;
handlePreview().then((success) => {
if (success) {
const url = new URL(newTabButton.href);
previewWindow.document.location = url.toString();
} else {
window.focus();
previewWindow.close();
}
});
};
const valid = await this.setPreviewDataWithAlert();
newTabButton.addEventListener('click', handlePreviewInNewTab);
// 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;
if (refreshButton) {
refreshButton.addEventListener('click', handlePreview);
return valid;
}
if (WAGTAIL_CONFIG.WAGTAIL_AUTO_UPDATE_PREVIEW) {
// Start with an empty payload so that when checkAndUpdatePreview is called
// for the first time when the panel is opened, it will always update the preview
let oldPayload = '';
let updateInterval;
/**
* 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;
const hasChanges = () => {
const newPayload = new URLSearchParams(new FormData(form)).toString();
const changed = oldPayload !== newPayload;
oldPayload = newPayload;
return changed;
};
// Call setPreviewData only if no changes have been made within the interval
const debouncedSetPreviewData = debounce(
setPreviewData,
WAGTAIL_CONFIG.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL,
);
const checkAndUpdatePreview = () => {
// Do not check for preview update if an update request is still pending
// and don't send a new request if the form hasn't changed
if (hasPendingUpdate || !hasChanges()) return;
debouncedSetPreviewData();
};
previewSidePanel.addEventListener('show', () => {
// Immediately update the preview when the panel is opened
checkAndUpdatePreview();
// Only set the interval while the panel is shown
// This interval performs the checks for changes but not necessarily the
// update itself
updateInterval = setInterval(
checkAndUpdatePreview,
WAGTAIL_CONFIG.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL,
);
});
// Use the same processing as the preview panel.
checksSidePanel?.addEventListener('show', () => {
checkAndUpdatePreview();
updateInterval = setInterval(
checkAndUpdatePreview,
WAGTAIL_CONFIG.WAGTAIL_AUTO_UPDATE_PREVIEW_INTERVAL,
);
});
previewSidePanel.addEventListener('hide', () => {
clearInterval(updateInterval);
});
checksSidePanel?.addEventListener('hide', () => {
clearInterval(updateInterval);
});
} else {
// Even if the preview is not updated automatically, we still need to
// initialise the preview data when the panel is shown
previewSidePanel.addEventListener('show', () => {
setPreviewData();
});
checksSidePanel?.addEventListener('show', () => {
setPreviewData();
});
this.formPayload = newPayload;
return changed;
}
//
// Preview mode handling
//
/**
* 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();
const handlePreviewModeChange = (event) => {
const mode = event.target.value;
const url = new URL(previewUrl, window.location.href);
// 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);
url.searchParams.delete('in_preview_panel');
newTabButton.href = url.toString();
this.newTabTarget.href = url.toString();
// Make sure data is updated
handlePreview();
};
if (previewModeSelect) {
previewModeSelect.addEventListener('change', handlePreviewModeChange);
// Make sure data is updated and an alert is displayed if an error occurs
this.setPreviewDataWithAlert();
}
// Remember last selected device size
let lastDevice = null;
try {
lastDevice = localStorage.getItem('wagtail:preview-panel-device');
} catch (e) {
// Initialise with the default device if the last one cannot be restored.
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);
this.checksSidePanel?.removeEventListener('show', this.activatePreview);
this.checksSidePanel?.removeEventListener('hide', this.deactivatePreview);
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();
}
const lastDeviceInput =
previewPanel.querySelector(`[data-device-width][value="${lastDevice}"]`) ||
defaultSizeInput;
lastDeviceInput.click();
}
document.addEventListener('DOMContentLoaded', () => {
initPreview();
});

Wyświetl plik

@ -16,6 +16,7 @@ import { InitController } from './InitController';
import { KeyboardController } from './KeyboardController';
import { LinkController } from './LinkController';
import { OrderableController } from './OrderableController';
import { PreviewController } from './PreviewController';
import { ProgressController } from './ProgressController';
import { RevealController } from './RevealController';
import { SessionController } from './SessionController';
@ -51,6 +52,7 @@ export const coreControllerDefinitions: Definition[] = [
{ controllerConstructor: KeyboardController, identifier: 'w-kbd' },
{ controllerConstructor: LinkController, identifier: 'w-link' },
{ controllerConstructor: OrderableController, identifier: 'w-orderable' },
{ controllerConstructor: PreviewController, identifier: 'w-preview' },
{ controllerConstructor: ProgressController, identifier: 'w-progress' },
{ controllerConstructor: RevealController, identifier: 'w-breadcrumbs' },
{ controllerConstructor: RevealController, identifier: 'w-reveal' },

Wyświetl plik

@ -201,7 +201,7 @@ export const renderA11yResults = (
messages?.help_text || violation.description;
// Special-case when displaying accessibility results within the admin interface.
const isInCMS = node.target[0] === '#preview-iframe';
const isInCMS = node.target[0] === '#w-preview-iframe';
const selectorName = toSelector(
isInCMS ? node.target[1] : node.target[0],
);

Wyświetl plik

@ -11,9 +11,9 @@ export const wagtailPreviewPlugin: AxePlugin = {
id: 'wagtailPreview',
run(id, action, options, callback) {
// Outside the preview frame, we need to send the command to the preview iframe.
const preview = document.querySelector<HTMLIFrameElement>(
'[data-preview-iframe]',
);
const preview = document.getElementById(
'w-preview-iframe',
) as HTMLIFrameElement;
if (preview) {
// @ts-expect-error Not declared in the official Axe Utils API.

Wyświetl plik

@ -7,6 +7,5 @@
<script src="{% versioned_static 'wagtailadmin/js/comments.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/vendor/rangy-core.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/expanding-formset.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/preview-panel.js' %}"></script>
<script src="{% versioned_static 'wagtailadmin/js/privacy-switch.js' %}"></script>
{% hook_output 'insert_editor_js' %}

Wyświetl plik

@ -2,31 +2,52 @@
{% wagtail_config as settings %}
<div class="preview-panel preview-panel--mobile" data-preview-panel data-action="{{ preview_url }}">
<div
class="preview-panel"
data-controller="w-preview"
data-w-preview-has-errors-class="preview-panel--has-errors"
data-w-preview-selected-size-class="preview-panel__size-button--selected"
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="preview-panel__sizes">
<label class="preview-panel__size-button preview-panel__size-button--mobile">
<label class="preview-panel__size-button">
{% icon name="mobile-alt" %}
<input type="radio" name="preview-size" value="mobile" data-device-width="375" data-default-size checked aria-label="{% trans 'Preview in mobile size' %}" />
<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' %}" />
</label>
<label class="preview-panel__size-button preview-panel__size-button--tablet">
<label class="preview-panel__size-button">
{% icon name="tablet-alt" %}
<input type="radio" name="preview-size" value="tablet" data-device-width="768" aria-label="{% trans 'Preview in tablet size' %}" />
<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' %}" />
</label>
<label class="preview-panel__size-button preview-panel__size-button--desktop">
<label class="preview-panel__size-button">
{% icon name="desktop" %}
<input type="radio" name="preview-size" value="desktop" data-device-width="1280" aria-label="{% trans 'Preview in desktop size' %}" />
<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' %}" />
</label>
<a href="{{ preview_url }}?mode={{ object.default_preview_mode|urlencode }}" target="_blank" rel="noreferrer" class="preview-panel__size-button preview-panel__size-button--new-tab" data-preview-new-tab aria-label="{% trans 'Preview in new tab' %}">{% icon name="link-external" %}</a>
<a href="{{ preview_url }}?mode={{ object.default_preview_mode|urlencode }}" target="_blank" rel="noreferrer" class="preview-panel__size-button" data-w-preview-target="newTab" data-action="w-preview#openPreviewInNewTab" aria-label="{% trans 'Preview in new tab' %}">{% icon name="link-external" %}</a>
</div>
<div class="preview-panel__spinner w-hidden" data-preview-spinner>
{% icon name="spinner" classname="default" %}
<span class="w-sr-only">{% trans 'Loading'%}</span>
</div>
{% if not settings.WAGTAIL_AUTO_UPDATE_PREVIEW %}
<button class="button button-small button-secondary button--icon preview-panel__refresh-button" data-refresh-preview>{% icon name="rotate" %} Refresh</button>
{% if settings.WAGTAIL_AUTO_UPDATE_PREVIEW %}
<div class="preview-panel__spinner" data-w-preview-target="spinner" hidden>
{% icon name="spinner" classname="default" %}
<span class="w-sr-only">{% trans 'Loading'%}</span>
</div>
{% else %}
<button
type="button"
class="button button-small button-secondary button-longrunning button--icon preview-panel__refresh-button"
data-controller="w-progress"
data-action="click->w-preview#setPreviewDataWithAlert click->w-progress#activate"
data-w-progress-active-value="{% trans 'Refreshing…' %}"
data-w-progress-duration-seconds-value="300"
>
{% icon name="rotate" classname="button-longrunning__icon" %}
{% icon name="spinner" %}
{# Using sr-only for the label as there might not be enough room for the label if the panel is resized #}
<em class="w-sr-only" data-w-progress-target="label">{% trans 'Refresh' %}</em>
</button>
{% endif %}
<div class="preview-panel__area">
@ -44,7 +65,7 @@
</div>
{% if has_multiple_modes %}
{% rawformattedfield label_text=_("Preview mode") id_for_label="id_preview_mode" classname="preview-panel__modes" %}
<select id="id_preview_mode" name="preview_mode" class="preview-panel__mode-select" data-preview-mode-select>
<select id="id_preview_mode" name="preview_mode" class="preview-panel__mode-select" data-w-preview-target="mode" data-action="w-preview#setPreviewMode">
{% for internal_name, display_name in object.preview_modes %}
<option value="{{ internal_name }}"{% if internal_name == object.default_preview_mode %} selected{% endif %}>{{ display_name }}</option>
{% endfor %}
@ -54,7 +75,14 @@
</div>
<div class="preview-panel__wrapper">
<iframe id="preview-iframe" loading="lazy" title="{% trans 'Preview' %}" class="preview-panel__iframe" data-preview-iframe aria-describedby="preview-panel-error-banner">
<iframe
id="w-preview-iframe"
title="{% trans 'Preview' %}"
class="preview-panel__iframe"
data-w-preview-target="iframe"
data-action="load->w-preview#replaceIframe"
aria-describedby="preview-panel-error-banner"
>
<div>
{# Add placeholder element to support styling content when iframe has loaded #}
</div>