kopia lustrzana https://github.com/wagtail/wagtail
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 availabilitypull/12295/head
rodzic
5aa0dde2a4
commit
55d57be7c5
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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' },
|
||||
|
|
|
@ -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],
|
||||
);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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' %}
|
||||
|
|
|
@ -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>
|
||||
|
|
Ładowanie…
Reference in New Issue