From 3b9f77bda57748188a861149ad3630967af5cece Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Thu, 19 Mar 2026 11:17:16 +0800 Subject: [PATCH 01/10] Add on page resizable preview panel --- _build/data/settings.php | 13 + assets/components/magicpreview/css/mgr.css | 300 ++++++- assets/components/magicpreview/js/preview.js | 793 ++++++++++++++++-- .../magicpreview/docs/changelog.txt | 12 + .../elements/plugins/magicpreview.plugin.php | 17 +- .../magicpreview/lexicon/da/default.inc.php | 9 +- .../magicpreview/lexicon/de/default.inc.php | 9 +- .../magicpreview/lexicon/en/default.inc.php | 7 + .../model/magicpreview/magicpreview.class.php | 2 +- 9 files changed, 1080 insertions(+), 82 deletions(-) diff --git a/_build/data/settings.php b/_build/data/settings.php index e92ddad..bdbd94c 100644 --- a/_build/data/settings.php +++ b/_build/data/settings.php @@ -21,4 +21,17 @@ 'area' => 'Preview', 'value' => '' ], + 'preview_mode' => [ + 'area' => 'Preview', + 'value' => 'newwindow', + ], + 'panel_layout' => [ + 'area' => 'Preview', + 'value' => 'overlay', + ], + 'auto_preview' => [ + 'area' => 'Preview', + 'value' => false, + 'xtype' => 'combo-boolean', + ], ]; \ No newline at end of file diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index af85730..654d9ca 100644 --- a/assets/components/magicpreview/css/mgr.css +++ b/assets/components/magicpreview/css/mgr.css @@ -1,3 +1,11 @@ +/* ========================================================================== + MagicPreview - Manager Styles + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Button styling: merge Preview + View buttons + -------------------------------------------------------------------------- */ + .magicpreview_modx2 #modx-abtn-real-preview { padding-right: 25px; } @@ -9,10 +17,300 @@ } #modx-abtn-preview:before { - content: "↗"; + content: "\2197"; } #modx-abtn-preview .x-btn-text { text-indent: -1000px; overflow: auto; } + +/* -------------------------------------------------------------------------- + Preview Panel: shared styles + + The panel is always fixed to the right edge of the viewport. + -------------------------------------------------------------------------- */ + +.mmmp-panel { + display: flex; + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 40%; + min-width: 320px; + max-width: 80%; + z-index: 12000; + flex-direction: column; + background: #f5f5f5; +} + +/* -------------------------------------------------------------------------- + Preview Panel: overlay layout + Panel slides in from the right and floats over the editor content. + -------------------------------------------------------------------------- */ + +.mmmp-panel--overlay { + transition: transform 300ms ease; + transform: translateX(100%); + pointer-events: none; +} + +.mmmp-panel--overlay.mmmp-panel--open { + transform: translateX(0); + pointer-events: auto; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15); +} + +/* -------------------------------------------------------------------------- + Preview Panel: onpage layout + Panel sits as a permanent column alongside the editor. The ExtJS + Viewport's size calculation is overridden in JS so the border layout + recalculates within the narrower space. The action buttons bar offset + is also managed dynamically by JS (syncActionButtonsOffset). + -------------------------------------------------------------------------- */ + +.mmmp-panel--onpage { + transform: translateX(100%); + pointer-events: none; +} + +.mmmp-panel--onpage.mmmp-panel--open { + transform: none; + pointer-events: auto; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); +} + +/* -------------------------------------------------------------------------- + Preview Panel: resize handle + A thin invisible strip along the left edge of the panel that the user + can click and drag to resize the panel horizontally. + -------------------------------------------------------------------------- */ + +.mmmp-panel__resize-handle { + position: absolute; + top: 0; + left: -4px; + width: 8px; + bottom: 0; + cursor: col-resize; + z-index: 1; +} + +.mmmp-panel__resize-handle:hover, +.mmmp-panel__resize-handle:active { + background: hsla(213, 66%, 55%, 0.3); +} + +/* -------------------------------------------------------------------------- + Preview Panel: toolbar + -------------------------------------------------------------------------- */ + +.mmmp-panel__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + background: #123764; + color: #fff; + padding: 0.5rem 0.75rem; + flex-shrink: 0; +} + +.mmmp-panel__breakpoints { + display: flex; +} + +.mmmp-panel__bp-btn { + display: block; + padding: 0.45rem 0.75rem; + background: hsla(213, 66%, 35%, 1); + color: hsla(213, 66%, 85%, 1); + border: none; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 600; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin-right: -1px; + position: relative; + box-shadow: inset 0 2px 2px hsla(0, 0%, 0%, 0.1), inset 0 -2px 0 hsla(0, 0%, 100%, 0.15); +} + +.mmmp-panel__bp-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.mmmp-panel__bp-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.mmmp-panel__bp-btn:hover, +.mmmp-panel__bp-btn:focus { + background: hsla(213, 66%, 45%, 1); + color: hsla(213, 66%, 95%, 1); +} + +.mmmp-panel__bp-btn--active { + background: hsla(213, 66%, 55%, 1); + color: hsla(213, 66%, 95%, 1); +} + +.mmmp-panel__close { + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + padding: 0.4rem; + display: flex; + align-items: center; +} + +.mmmp-panel__close svg { + width: 18px; + height: 18px; + color: #fff; +} + +.mmmp-panel__close:hover, +.mmmp-panel__close:focus { + background: #4185d8; +} + +.mmmp-panel__actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.mmmp-panel__reload { + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + padding: 0.4rem; + display: flex; + align-items: center; +} + +.mmmp-panel__reload svg { + width: 18px; + height: 18px; + color: #fff; +} + +.mmmp-panel__reload:hover, +.mmmp-panel__reload:focus { + background: #4185d8; +} + +/* -------------------------------------------------------------------------- + Preview Panel: content area (iframe + loading) + -------------------------------------------------------------------------- */ + +.mmmp-panel__content { + flex: 1; + overflow: auto; + position: relative; + background: #e8e8e8; +} + +.mmmp-panel__frame-wrapper { + width: 100%; + height: 100%; + transition: width 400ms ease; +} + +.mmmp-panel__iframe { + width: 100%; + height: 100%; + border: none; + display: block; + background: #fff; +} + +.mmmp-panel__loading { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + text-align: center; + color: #666; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #f5f5f5; +} + +.mmmp-panel__idle { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1rem; + text-align: center; + color: #999; + font-size: 0.875rem; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #f5f5f5; +} + +/* Reuse the loading animation from preview.css */ +.mmmp-panel__loading .mmmp-c-animation { + display: inline-block; + position: relative; + width: 64px; + height: 64px; +} + +.mmmp-panel__loading .mmmp-c-animation div { + position: absolute; + top: 21px; + width: 11px; + height: 11px; + border-radius: 50%; + background: #123764; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} + +.mmmp-panel__loading .mmmp-c-animation div:nth-child(1) { + left: 6px; + animation: ellipsis1 0.6s infinite; +} + +.mmmp-panel__loading .mmmp-c-animation div:nth-child(2) { + left: 6px; + animation: ellipsis2 0.6s infinite; +} + +.mmmp-panel__loading .mmmp-c-animation div:nth-child(3) { + left: 26px; + animation: ellipsis2 0.6s infinite; +} + +.mmmp-panel__loading .mmmp-c-animation div:nth-child(4) { + left: 45px; + animation: ellipsis3 0.6s infinite; +} + +@keyframes ellipsis1 { + 0% { transform: scale(0); } + 100% { transform: scale(1); } +} + +@keyframes ellipsis2 { + 0% { transform: translate(0, 0); } + 100% { transform: translate(19px, 0); } +} + +@keyframes ellipsis3 { + 0% { transform: scale(1); } + 100% { transform: scale(0); } +} diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index a6d2748..ddec29a 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -1,12 +1,709 @@ +/** + * MagicPreview - Preview unsaved resource changes + * + * Provides a global MagicPreview API for showing previews in either + * a new browser window or an inline side panel. The API is consumed both + * internally (via the Preview button) and externally (e.g. VersionX). + * + * @global {object} MagicPreviewConfig - Injected by PHP plugin + * @global {number} MagicPreviewResource - Injected by PHP plugin + */ (function() { + // ========================================================================= + // Configuration (lazy-loaded: resolved on first access because the globals + // MagicPreviewConfig, MagicPreviewResource and MODx.config may not exist + // yet when this script file is parsed) + // ========================================================================= + + var _config = null; + + /** + * Resolves and caches all configuration values from the PHP-injected globals. + * Called lazily on first use. + * @returns {object} + */ + function config() { + if (_config) return _config; + + var baseFrameUrl = MagicPreviewConfig.baseFrameUrl || ''; + _config = { + previewUrl: MODx.config.manager_url + + '?namespace=magicpreview&a=preview&resource=' + MagicPreviewResource, + baseFrameUrl: baseFrameUrl, + frameJoiner: baseFrameUrl.indexOf('?') === -1 ? '?' : '&', + previewMode: MagicPreviewConfig.previewMode || 'newwindow', + panelLayout: MagicPreviewConfig.panelLayout || 'overlay', + autoPreview: !!MagicPreviewConfig.autoPreview, + breakpoints: MagicPreviewConfig.breakpoints || {}, + }; + + return _config; + } + + // ========================================================================= + // Panel DOM management + // ========================================================================= + + /** @type {HTMLElement|null} */ + var panelEl = null; + /** @type {HTMLIFrameElement|null} */ + var panelIframe = null; + /** @type {HTMLElement|null} */ + var panelLoading = null; + /** @type {HTMLElement|null} */ + var panelIdle = null; + /** @type {string|null} The last preview hash loaded into the panel */ + var lastPanelHash = null; + + /** + * Creates the panel DOM structure and appends it to the document body. + * The panel is hidden by default (no .mmmp-panel--open class). + */ + function createPanel() { + if (panelEl) return; + + var c = config(); + + panelEl = document.createElement('div'); + panelEl.id = 'mmmp-panel'; + panelEl.className = 'mmmp-panel mmmp-panel--' + c.panelLayout; + + // Build breakpoint buttons HTML + var bpKeys = [ + { key: 'full', label: 'Full' }, + { key: 'desktop', label: 'Desktop' }, + { key: 'tablet', label: 'Tablet' }, + { key: 'mobile', label: 'Mobile' } + ]; + var bpHtml = ''; + for (var i = 0; i < bpKeys.length; i++) { + var bp = bpKeys[i]; + var active = bp.key === 'full' ? ' mmmp-panel__bp-btn--active' : ''; + bpHtml += ''; + } + + panelEl.innerHTML = '' + + '
' + + '
' + bpHtml + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '

Preparing your preview...

' + + '
' + + '
' + + '

Click Preview to generate a preview.

' + + '
' + + '
'; + + document.body.appendChild(panelEl); + + panelIframe = document.getElementById('mmmp-panel-iframe'); + panelLoading = panelEl.querySelector('.mmmp-panel__loading'); + panelIdle = panelEl.querySelector('.mmmp-panel__idle'); + + // Resize handle on the left edge of the panel + var resizeHandle = document.createElement('div'); + resizeHandle.className = 'mmmp-panel__resize-handle'; + panelEl.appendChild(resizeHandle); + initResize(resizeHandle); + + // Close button + panelEl.querySelector('.mmmp-panel__close').addEventListener('click', function() { + MagicPreview.close(); + }); + + // Reload button: re-submits the form to regenerate the preview + panelEl.querySelector('.mmmp-panel__reload').addEventListener('click', function() { + submitPreview(); + }); + + // Breakpoint buttons + var bpBtns = panelEl.querySelectorAll('.mmmp-panel__bp-btn'); + for (var b = 0; b < bpBtns.length; b++) { + bpBtns[b].addEventListener('click', function() { + setBreakpoint(this.getAttribute('data-bp')); + + // Update active state + for (var j = 0; j < bpBtns.length; j++) { + bpBtns[j].classList.remove('mmmp-panel__bp-btn--active'); + } + this.classList.add('mmmp-panel__bp-btn--active'); + }); + } + } + + /** + * Sets the iframe content width based on the selected breakpoint. + * The panel width stays fixed; the frame wrapper width changes so + * the content overflows horizontally and becomes scrollable. + * @param {string} bp - Breakpoint key: 'full', 'desktop', 'tablet', 'mobile' + */ + function setBreakpoint(bp) { + if (!panelEl) return; + + var c = config(); + var frameWrapper = panelEl.querySelector('.mmmp-panel__frame-wrapper'); + var width; + + switch (bp) { + case 'desktop': + width = c.breakpoints.desktop || '1280px'; + break; + case 'tablet': + width = c.breakpoints.tablet || '768px'; + break; + case 'mobile': + width = c.breakpoints.mobile || '320px'; + break; + default: + width = '100%'; + } + + frameWrapper.style.width = width; + } + + /** + * Shows the loading state in the panel. + */ + function showPanelLoading() { + if (!panelIframe || !panelLoading) return; + panelIframe.parentElement.style.display = 'none'; + panelLoading.style.display = 'flex'; + if (panelIdle) panelIdle.style.display = 'none'; + panelIframe.src = ''; + } + + /** + * Shows the idle/placeholder state in the panel (no preview yet). + */ + function showPanelIdle() { + if (!panelIframe || !panelIdle) return; + panelIframe.parentElement.style.display = 'none'; + panelLoading.style.display = 'none'; + panelIdle.style.display = 'flex'; + panelIframe.src = ''; + } + + /** + * Loads a preview hash into the panel iframe. + * @param {string} hash - The preview hash from the processor + */ + function showPanelPreview(hash) { + if (!panelIframe || !panelLoading) return; + var c = config(); + lastPanelHash = hash; + panelLoading.style.display = 'none'; + if (panelIdle) panelIdle.style.display = 'none'; + panelIframe.parentElement.style.display = 'block'; + panelIframe.src = c.baseFrameUrl + c.frameJoiner + 'show_preview=' + hash; + } + + /** @type {boolean} Whether the window resize handler has been registered */ + var resizeHandlerRegistered = false; + + /** + * Opens the panel and applies the layout mode. + */ + function openPanel() { + createPanel(); + + // Restore previously set custom width from drag-resize + if (customPanelWidth) { + panelEl.style.width = customPanelWidth + 'px'; + } + + panelEl.classList.add('mmmp-panel--open'); + + if (config().panelLayout === 'onpage') { + document.body.classList.add('mmmp-panel-onpage-active'); + syncActionButtonsOffset(); + relayoutModx(); + + // Register the resize handler once so the layout stays + // correct when the browser window is resized. + if (!resizeHandlerRegistered) { + resizeHandlerRegistered = true; + Ext.EventManager.onWindowResize(function() { + if (document.body.classList.contains('mmmp-panel-onpage-active')) { + syncActionButtonsOffset(); + relayoutModx(); + } + }); + } + } + } + + /** + * Closes the panel and restores the editor layout. + */ + function closePanel() { + if (!panelEl) return; + panelEl.classList.remove('mmmp-panel--open'); + document.body.classList.remove('mmmp-panel-onpage-active'); + syncActionButtonsOffset(); + + if (panelIframe) { + panelIframe.src = ''; + } + lastPanelHash = null; + + relayoutModx(); + } + + /** + * Returns the panel's current pixel width, accounting for CSS + * min-width / max-width clamping. + * @returns {number} + */ + function getPanelWidth() { + if (panelEl && panelEl.classList.contains('mmmp-panel--open')) { + return panelEl.offsetWidth; + } + return 0; + } + + // ========================================================================= + // Drag-to-resize + // ========================================================================= + + /** @type {number|null} Custom panel width set by drag, in pixels */ + var customPanelWidth = null; + + /** + * Initialises drag-to-resize on the given handle element. + * Dragging the left edge of the panel resizes it horizontally. + * On mouse-up, the new width is applied and the ExtJS layout is + * recalculated (for onpage mode). + * @param {HTMLElement} handle + */ + function initResize(handle) { + var startX, startWidth; + + handle.addEventListener('mousedown', function(e) { + e.preventDefault(); + startX = e.clientX; + startWidth = panelEl.offsetWidth; + + // Disable iframe pointer events during drag so mousemove + // isn't swallowed by the iframe + if (panelIframe) panelIframe.style.pointerEvents = 'none'; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + function onMouseMove(e) { + // Panel is on the right, so dragging left (decreasing clientX) should increase width + var newWidth = startWidth + (startX - e.clientX); + + // Clamp to min/max from CSS (320px min, 80% of viewport max) + var minW = 320; + var maxW = window.innerWidth * 0.8; + newWidth = Math.max(minW, Math.min(maxW, newWidth)); + + panelEl.style.width = newWidth + 'px'; + syncActionButtonsOffset(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + + if (panelIframe) panelIframe.style.pointerEvents = ''; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + customPanelWidth = panelEl.offsetWidth; + + // For onpage mode, recalculate the ExtJS layout + if (config().panelLayout === 'onpage' && document.body.classList.contains('mmmp-panel-onpage-active')) { + relayoutModx(); + } + } + } + + /** + * Synchronises the fixed-position action buttons bar's right offset + * with the panel's current width, so it doesn't extend behind the panel. + * Only applies in onpage mode when the panel is open. + */ + function syncActionButtonsOffset() { + var actionBar = document.getElementById('modx-action-buttons'); + if (!actionBar) return; + + if (config().panelLayout === 'onpage' && document.body.classList.contains('mmmp-panel-onpage-active')) { + actionBar.style.right = getPanelWidth() + 'px'; + } else { + actionBar.style.right = ''; + } + } + + /** @type {Function|null} Stored reference to the original getViewSize method */ + var _originalGetViewSize = null; + + /** + * Overrides the ExtJS Viewport's size calculation so its border + * layout positions panels within a narrower area, leaving room + * for our preview panel on the right. + * + * Ext.Viewport always measures document.body, and its getViewSize() + * returns window.innerWidth regardless of any CSS width constraints. + * We override that method on the Viewport's element so the border + * layout reads our reduced width, then call doLayout() to trigger + * a full recalculation. + */ + function relayoutModx() { + var layout = Ext.getCmp('modx-layout'); + if (!layout) return; + + var isOpen = document.body.classList.contains('mmmp-panel-onpage-active'); + + if (isOpen) { + // Store the original on first override + if (!_originalGetViewSize) { + _originalGetViewSize = layout.el.getViewSize.bind(layout.el); + } + + var pw = getPanelWidth(); + layout.el.getViewSize = function() { + return { + width: window.innerWidth - pw, + height: window.innerHeight + }; + }; + } else if (_originalGetViewSize) { + // Remove the instance override so the prototype method is used again + delete layout.el.getViewSize; + _originalGetViewSize = null; + } + + // Delay to allow the panel to render and be measurable + setTimeout(function() { + // Re-read panel width now that it's in the DOM + if (isOpen) { + var pw = getPanelWidth(); + layout.el.getViewSize = function() { + return { + width: window.innerWidth - pw, + height: window.innerHeight + }; + }; + } + layout.doLayout(); + }, 50); + } + + /** + * For "onpage" layout with auto-preview: opens the panel immediately + * on page load so it appears as a column alongside the editor with + * a loading state while the preview is being generated. + * + * When auto-preview is off, the panel stays hidden until the user + * clicks Preview — openPanel() handles everything at that point. + */ + function initOnpagePanel() { + if (config().panelLayout !== 'onpage') return; + if (config().previewMode !== 'panel') return; + if (!config().autoPreview) return; + + createPanel(); + panelEl.classList.add('mmmp-panel--open'); + document.body.classList.add('mmmp-panel-onpage-active'); + showPanelLoading(); + + syncActionButtonsOffset(); + relayoutModx(); + + // Register the resize handler so the layout stays correct + // when the browser window is resized. + resizeHandlerRegistered = true; + Ext.EventManager.onWindowResize(function() { + if (document.body.classList.contains('mmmp-panel-onpage-active')) { + syncActionButtonsOffset(); + relayoutModx(); + } + }); + } + + /** + * Triggers auto-preview by submitting the form after the resource + * panel has finished rendering. Only runs when autoPreview is + * enabled and previewMode is 'panel'. + */ + function initAutoPreview() { + if (!config().autoPreview) return; + if (config().previewMode !== 'panel') return; + + // For overlay mode, open the panel first (onpage is already open via initOnpagePanel) + if (config().panelLayout === 'overlay') { + openPanel(); + showPanelLoading(); + } + + // Wait for the resource panel to be available, then submit. + // Use a short delay to let MODX finish initialising the form + // (RTEs, resource groups, etc.) before we try to read the form data. + var checkInterval = setInterval(function() { + var panel = Ext.getCmp('modx-panel-resource'); + if (panel && panel.getForm()) { + clearInterval(checkInterval); + // Give RTEs a moment to initialise their content + setTimeout(function() { + submitPreview(); + }, 500); + } + }, 100); + } + + // ========================================================================= + // Popup window management + // ========================================================================= + + /** @type {Window|null} */ + var popupWindow = null; + + /** + * Opens or focuses the popup window with a loading state. + */ + function openPopup() { + var c = config(); + if (popupWindow && !popupWindow.closed) { + popupWindow.focus(); + } else { + popupWindow = window.open(c.previewUrl + '#loading', 'MagicPreview_' + MagicPreviewResource); + popupWindow.opener = window; + } + } + + /** + * Navigates the popup window to the given preview hash. + * @param {string} hash - The preview hash from the processor + */ + function showPopupPreview(hash) { + if (popupWindow && !popupWindow.closed) { + popupWindow.location = config().previewUrl + '#' + hash; + } + } + + /** + * Closes the popup window. + */ + function closePopup() { + if (popupWindow && !popupWindow.closed) { + popupWindow.close(); + } + popupWindow = null; + } + + // ========================================================================= + // Public API: window.MagicPreview + // ========================================================================= + + /** + * Global MagicPreview API. + * + * Used internally by the Preview button and externally by consumers + * like VersionX. The API abstracts over new window vs panel mode so + * consumers don't need to know the display mechanism. + * + * @namespace MagicPreview + */ + window.MagicPreview = window.MagicPreview || {}; + + /** + * Show a preview for the given hash. Opens the new window or panel + * depending on the configured preview mode. + * + * @param {string} hash - The preview hash returned by the processor. + * Pass 'loading' or empty string to show the loading state. + */ + window.MagicPreview.show = function(hash) { + if (config().previewMode === 'panel') { + openPanel(); + if (!hash || hash === 'loading') { + showPanelLoading(); + } else { + showPanelPreview(hash); + } + } else { + openPopup(); + if (hash && hash !== 'loading') { + showPopupPreview(hash); + } + } + }; + + /** + * Close the preview (new window or panel). + */ + window.MagicPreview.close = function() { + if (config().previewMode === 'panel') { + closePanel(); + } else { + closePopup(); + } + }; + + /** + * Check if the preview is currently open/visible. + * @returns {boolean} + */ + window.MagicPreview.isOpen = function() { + if (config().previewMode === 'panel') { + return panelEl !== null && panelEl.classList.contains('mmmp-panel--open'); + } else { + return popupWindow !== null && !popupWindow.closed; + } + }; + + /** + * Get the current preview mode. + * @returns {string} 'newwindow' or 'panel' + */ + window.MagicPreview.getMode = function() { + return config().previewMode; + }; + + /** + * Get the popup preview URL (used by VersionX to build custom URLs + * with additional query parameters like revert type and version). + * @returns {string} + */ + window.MagicPreview.getPreviewUrl = function() { + return config().previewUrl; + }; + + /** + * Get the popup window reference (for consumers that need to set + * custom location with additional query params, e.g. VersionX). + * Returns null if in panel mode or if no popup is open. + * @returns {Window|null} + */ + window.MagicPreview.getPopupWindow = function() { + if (popupWindow && !popupWindow.closed) { + return popupWindow; + } + return null; + }; + + /** + * Open/focus the new window or panel in a loading state, without a hash. + * Used before the processor request is made. + */ + window.MagicPreview.showLoading = function() { + window.MagicPreview.show('loading'); + }; + + // ========================================================================= + // Form submission: submits resource data to the preview processor + // ========================================================================= + + /** + * Submits the resource form to the MagicPreview processor. + * + * Bypasses MODx.FormPanel.submit() entirely to avoid the save mask + * (waitMsg), tree refresh, dirty-state reset, and "Save successful" + * status message that MODX's default success handler triggers. + * + * Instead, we: + * 1. Fire the panel's beforeSubmit event (syncs RTE content, encodes + * resource groups — same as a normal save). + * 2. Submit the BasicForm directly to our connector with no waitMsg. + * 3. Handle the response in success/failure callbacks that never touch + * the FormPanel-level success event. + */ + function submitPreview() { + var panel = Ext.getCmp('modx-panel-resource'); + if (!panel) return; + + var fm = panel.getForm(); + if (!fm) return; + + // Show loading state immediately + MagicPreview.showLoading(); + + // Validate form fields + var isValid = true; + if (fm.items && fm.items.items) { + for (var fld in fm.items.items) { + if (fm.items.items[fld] && fm.items.items[fld].validate) { + if (!fm.items.items[fld].validate()) { + fm.items.items[fld].markInvalid(); + isValid = false; + } + } + } + } + if (!isValid) return; + + // Fire beforeSubmit on the panel so RTE content is synced, + // resource groups are encoded, etc. — same as a normal save. + var canSubmit = panel.fireEvent('beforeSubmit', { + form: fm, + options: {}, + config: panel.config + }); + if (canSubmit === false) return; + + // Stash and swap the action + URL on the BasicForm so ExtJS + // posts to our preview connector instead of the real save endpoint. + var originalAction = fm.baseParams['action']; + var originalUrl = fm.url; + fm.baseParams['action'] = 'resource/preview'; + fm.url = MagicPreviewConfig.assetsUrl + 'connector.php'; + + fm.submit({ + // No waitMsg — prevents the "Saving..." mask + headers: { + 'Powered-By': 'MODx', + 'modAuth': MODx.siteId + }, + success: function(form, action) { + fm.baseParams['action'] = originalAction; + fm.url = originalUrl; + + var result = action.result; + if (result && result.object && result.object.preview_hash) { + MagicPreview.show(result.object.preview_hash); + } + }, + failure: function(form, action) { + fm.baseParams['action'] = originalAction; + fm.url = originalUrl; + } + }); + } + + // ========================================================================= + // ExtJS: Button injection + // ========================================================================= + Ext.onReady(function() { - var previewUrl = MODx.config.manager_url - + '?namespace=magicpreview&a=preview&resource=' + MagicPreviewResource, - basePage = MODx.page.UpdateResource; + var basePage = MODx.page.UpdateResource; - // Check for custom page types and extend those instead - // If a custom resource is loaded it's type will be 'object'. - // TODO: Add other custom objects here + // Check for custom page types and extend those instead. + // If a custom resource is loaded its type will be 'object'. switch ('object') { case typeof Articles: basePage = Articles.page.UpdateArticle ?? basePage; @@ -24,88 +721,30 @@ _originals: { getButtons: basePage.prototype.getButtons }, - getButtons: function(config) { - var btns = this._originals.getButtons.call(this, config); - var btnView = btns.map((btn) => { return btn.id }).indexOf("modx-abtn-preview"); + getButtons: function(cfg) { + var btns = this._originals.getButtons.call(this, cfg); + var btnView = btns.map(function(btn) { return btn.id; }).indexOf('modx-abtn-preview'); btns.splice(btnView, 0, { text: 'Preview', id: 'modx-abtn-real-preview', - handler: this.mpPreview, + handler: function() { submitPreview(); }, scope: this }); return btns; }, - /** - * Run when the preview button is clicked - * @returns {boolean} - */ - mpPreview: function() { - var o = this.config; - if (!o.formpanel) return false; - - MODx.util.Progress.reset(); - - // Get the resource form panel - o.form = Ext.getCmp(o.formpanel); - if (!o.form) return false; - - if (o.previewWindow && !o.previewWindow.closed) { - o.previewWindow.focus(); - } else { - o.previewWindow = window.open(previewUrl + '#loading', 'MagicPreview_' + MagicPreviewResource) - o.previewWindow.opener = window; // Pass reference to the main window - } - - // Check if all the fields in the form are valid - var f = o.form.getForm ? o.form.getForm() : o.form; - var isv = true; - if (f.items && f.items.items) { - for (var fld in f.items.items) { - if (f.items.items[fld] && f.items.items[fld].validate) { - var fisv = f.items.items[fld].validate(); - if (!fisv) { - f.items.items[fld].markInvalid(); - isv = false; - } - } - } - } - - // If the form is valid, push the data to the preview processor - if (isv) { - var originalAction = o.form.baseParams['action'], - originalUrl = o.form.url; - f.baseParams['action'] = 'resource/preview'; - f.url = MagicPreviewConfig.assetsUrl + 'connector.php'; - - // If the response from the processor is successful, set the new location in the preview window - o.form.on('success', function (r) { - f.baseParams['action'] = originalAction; - f.url = originalUrl; - - if (r.result && r.result.object && r.result.object.preview_hash) { - o.previewWindow.location = previewUrl + '#' + r.result.object.preview_hash; - } - - }, this); - - // Submit the form to the processor - o.form.submit({ - headers: { - 'Powered-By': 'MODx' - ,'modAuth': MODx.siteId - } - }); - } - }, - // Make sure the view button still has the preview url. - preview: function(config) { - window.open(config.scope.preview_url); + preview: function(previewConfig) { + window.open(previewConfig.scope.preview_url); return false; } }); + + // For "onpage" panel layout, open the panel immediately as a + // permanent column alongside the resource editor. + initOnpagePanel(); + + // Auto-preview: submit the form immediately to generate a preview + initAutoPreview(); }); })(); - diff --git a/core/components/magicpreview/docs/changelog.txt b/core/components/magicpreview/docs/changelog.txt index f4c6688..c892007 100644 --- a/core/components/magicpreview/docs/changelog.txt +++ b/core/components/magicpreview/docs/changelog.txt @@ -1,3 +1,15 @@ +MagicPreview 1.6.0-pl +--------------------- +Released on 2026-xx-xx + +- Add inline side panel preview mode as an alternative to the popup window [#xx] +- Add magicpreview.preview_mode setting to switch between popup and panel modes +- Add magicpreview.panel_layout setting to control panel behavior (overlay or onpage) +- Add magicpreview.auto_preview setting to automatically generate a preview on page load +- Add drag-to-resize on the panel edge for both overlay and onpage layouts +- Expose global MagicPreview JS API (show/close/isOpen) for external consumers like VersionX +- Panel includes responsive breakpoint controls that set the iframe content width + MagicPreview 1.5.1-pl --------------------- Released on 2025-04-11 diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index ff7d2c9..198f1f2 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -19,6 +19,21 @@ $versionCls = 'magicpreview_modx3'; } + // Build the frontend URL for the resource (used by panel mode iframe) + $baseFrameUrl = $modx->makeUrl($resource->get('id'), '', '', 'full'); + + // Add preview mode and panel settings to the JS config + $jsConfig = $service->config; + $jsConfig['previewMode'] = $modx->getOption('magicpreview.preview_mode', null, 'newwindow'); + $jsConfig['panelLayout'] = $modx->getOption('magicpreview.panel_layout', null, 'overlay'); + $jsConfig['autoPreview'] = (bool)$modx->getOption('magicpreview.auto_preview', null, false); + $jsConfig['baseFrameUrl'] = $baseFrameUrl; + $jsConfig['breakpoints'] = [ + 'desktop' => $modx->getOption('magicpreview.breakpoint_desktop', null, '1280px'), + 'tablet' => $modx->getOption('magicpreview.breakpoint_tablet', null, '768px'), + 'mobile' => $modx->getOption('magicpreview.breakpoint_mobile', null, '320px'), + ]; + $modx->controller->addJavascript($service->config['assetsUrl'] . 'js/preview.js?v=' . $service::VERSION); $modx->controller->addHtml(' '); diff --git a/core/components/magicpreview/lexicon/da/default.inc.php b/core/components/magicpreview/lexicon/da/default.inc.php index 03bfca0..e278f1c 100644 --- a/core/components/magicpreview/lexicon/da/default.inc.php +++ b/core/components/magicpreview/lexicon/da/default.inc.php @@ -2,6 +2,7 @@ $_lang['magicpreview'] = 'Magisk forhåndsvisning'; $_lang['magicpreview.preview'] = 'Forhåndsvisning af '; $_lang['magicpreview.preparing_preview'] = 'Forbereder din forhåndsvisning...'; +$_lang['magicpreview.close_panel'] = 'Luk forhåndsvisningspanel'; $_lang['magicpreview.bp_full'] = 'Fuld størrelse'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; @@ -18,4 +19,10 @@ $_lang['setting_magicpreview.custom_preview_tpl'] = 'Aangepaste voorbeeldsjabloon'; $_lang['setting_magicpreview.custom_preview_tpl_desc'] = 'Het pad voor het sjabloonbestand dat moet worden geladen'; $_lang['setting_magicpreview.custom_preview_css'] = 'Custom Preview CSS'; -$_lang['setting_magicpreview.custom_preview_css_desc'] = 'Het pad voor het CSS-bestand dat moet worden geladen.'; \ No newline at end of file +$_lang['setting_magicpreview.custom_preview_css_desc'] = 'Het pad voor het CSS-bestand dat moet worden geladen.'; +$_lang['setting_magicpreview.preview_mode'] = 'Forhåndsvisningstilstand'; +$_lang['setting_magicpreview.preview_mode_desc'] = 'Hvordan forhåndsvisningen vises. "newwindow" åbner et nyt browservindue (standard). "panel" viser et sidepanel i manageren.'; +$_lang['setting_magicpreview.panel_layout'] = 'Panel-layout'; +$_lang['setting_magicpreview.panel_layout_desc'] = 'Hvordan forhåndsvisningspanelet interagerer med editoren. "overlay" svæver ovenpå (standard). "onpage" formindsker editoren for en permanent kolonne.'; +$_lang['setting_magicpreview.auto_preview'] = 'Automatisk forhåndsvisning'; +$_lang['setting_magicpreview.auto_preview_desc'] = 'Generer automatisk en forhåndsvisning, når ressourceformularen indlæses. Gælder kun når forhåndsvisningstilstanden er sat til "panel". Standard: Nej.'; \ No newline at end of file diff --git a/core/components/magicpreview/lexicon/de/default.inc.php b/core/components/magicpreview/lexicon/de/default.inc.php index 2379f8b..603d0e0 100644 --- a/core/components/magicpreview/lexicon/de/default.inc.php +++ b/core/components/magicpreview/lexicon/de/default.inc.php @@ -2,6 +2,7 @@ $_lang['magicpreview'] = 'Magische Vorschau'; $_lang['magicpreview.preview'] = 'Vorschau für '; $_lang['magicpreview.preparing_preview'] = 'Ihre Vorschau wird vorbereitet...'; +$_lang['magicpreview.close_panel'] = 'Vorschau-Panel schließen'; $_lang['magicpreview.bp_full'] = 'Volle Breite'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; $_lang['magicpreview.bp_tablet'] = 'Tablet'; @@ -17,4 +18,10 @@ $_lang['setting_magicpreview.custom_preview_tpl'] = 'Benutzerdefinierte Vorschau-Vorlage'; $_lang['setting_magicpreview.custom_preview_tpl_desc'] = 'Der Pfad für die zu ladende Vorlagendatei.'; $_lang['setting_magicpreview.custom_preview_css'] = 'Benutzerdefinierte Vorschau-CSS'; -$_lang['setting_magicpreview.custom_preview_css_desc'] = 'Der Pfad für die zu ladende CSS-Datei.'; \ No newline at end of file +$_lang['setting_magicpreview.custom_preview_css_desc'] = 'Der Pfad für die zu ladende CSS-Datei.'; +$_lang['setting_magicpreview.preview_mode'] = 'Vorschau-Modus'; +$_lang['setting_magicpreview.preview_mode_desc'] = 'Wie die Vorschau angezeigt wird. "newwindow" öffnet ein neues Browserfenster (Standard). "panel" zeigt ein Seitenpanel im Manager.'; +$_lang['setting_magicpreview.panel_layout'] = 'Panel-Layout'; +$_lang['setting_magicpreview.panel_layout_desc'] = 'Wie das Vorschau-Panel mit dem Editor interagiert. "overlay" schwebt darüber (Standard). "onpage" verkleinert den Editor für eine permanente Spalte.'; +$_lang['setting_magicpreview.auto_preview'] = 'Automatische Vorschau'; +$_lang['setting_magicpreview.auto_preview_desc'] = 'Automatisch eine Vorschau generieren, wenn das Ressourcen-Formular geladen wird. Gilt nur wenn der Vorschau-Modus auf "panel" eingestellt ist. Standard: Nein.'; \ No newline at end of file diff --git a/core/components/magicpreview/lexicon/en/default.inc.php b/core/components/magicpreview/lexicon/en/default.inc.php index 58c59c8..ed4c623 100644 --- a/core/components/magicpreview/lexicon/en/default.inc.php +++ b/core/components/magicpreview/lexicon/en/default.inc.php @@ -2,6 +2,7 @@ $_lang['magicpreview'] = 'Magic Preview'; $_lang['magicpreview.preview'] = 'Preview for '; $_lang['magicpreview.preparing_preview'] = 'Preparing your preview...'; +$_lang['magicpreview.close_panel'] = 'Close preview panel'; $_lang['magicpreview.bp_full'] = 'Full'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; @@ -19,4 +20,10 @@ $_lang['setting_magicpreview.custom_preview_tpl_desc'] = 'The path for the template file to load.'; $_lang['setting_magicpreview.custom_preview_css'] = 'Custom Preview CSS'; $_lang['setting_magicpreview.custom_preview_css_desc'] = 'The path for the CSS file to load.'; +$_lang['setting_magicpreview.preview_mode'] = 'Preview Mode'; +$_lang['setting_magicpreview.preview_mode_desc'] = 'How to display the preview. "newwindow" opens a new browser window (default). "panel" shows an inline side panel in the manager.'; +$_lang['setting_magicpreview.panel_layout'] = 'Panel Layout'; +$_lang['setting_magicpreview.panel_layout_desc'] = 'How the preview panel interacts with the editor. "overlay" floats on top (default). "onpage" shrinks the editor to make room for a permanent column.'; +$_lang['setting_magicpreview.auto_preview'] = 'Auto Preview'; +$_lang['setting_magicpreview.auto_preview_desc'] = 'Automatically generate a preview when the resource form loads. Only applies when preview mode is set to "panel". Default: No.'; diff --git a/core/components/magicpreview/model/magicpreview/magicpreview.class.php b/core/components/magicpreview/model/magicpreview/magicpreview.class.php index 4de20af..30a3395 100644 --- a/core/components/magicpreview/model/magicpreview/magicpreview.class.php +++ b/core/components/magicpreview/model/magicpreview/magicpreview.class.php @@ -22,7 +22,7 @@ class MagicPreview */ public $debug = false; - const VERSION = '1.5.1-pl'; + const VERSION = '1.6.0-pl'; /** From 8acd76b872ee9bae2697c0561e63e728d5e2eb12 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Thu, 19 Mar 2026 19:26:12 +0800 Subject: [PATCH 02/10] Auto refresh preview working --- _build/data/settings.php | 4 + assets/components/magicpreview/css/mgr.css | 2 +- assets/components/magicpreview/js/preview.js | 225 +++++++++++++++--- .../controllers/preview.class.php | 2 +- .../magicpreview/docs/changelog.txt | 1 + .../elements/plugins/magicpreview.plugin.php | 12 + .../magicpreview/lexicon/da/default.inc.php | 27 ++- .../magicpreview/lexicon/de/default.inc.php | 19 +- .../magicpreview/lexicon/en/default.inc.php | 5 + .../processors/resource/PreviewTrait.php | 5 +- 10 files changed, 243 insertions(+), 59 deletions(-) diff --git a/_build/data/settings.php b/_build/data/settings.php index bdbd94c..a9f94f4 100644 --- a/_build/data/settings.php +++ b/_build/data/settings.php @@ -34,4 +34,8 @@ 'value' => false, 'xtype' => 'combo-boolean', ], + 'auto_refresh_interval' => [ + 'area' => 'Preview', + 'value' => '5', + ], ]; \ No newline at end of file diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index 654d9ca..f79ee0c 100644 --- a/assets/components/magicpreview/css/mgr.css +++ b/assets/components/magicpreview/css/mgr.css @@ -40,7 +40,6 @@ width: 40%; min-width: 320px; max-width: 80%; - z-index: 12000; flex-direction: column; background: #f5f5f5; } @@ -51,6 +50,7 @@ -------------------------------------------------------------------------- */ .mmmp-panel--overlay { + z-index: 12000; transition: transform 300ms ease; transform: translateX(100%); pointer-events: none; diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index ddec29a..6d9d7b9 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -34,12 +34,24 @@ previewMode: MagicPreviewConfig.previewMode || 'newwindow', panelLayout: MagicPreviewConfig.panelLayout || 'overlay', autoPreview: !!MagicPreviewConfig.autoPreview, + autoRefreshInterval: parseInt(MagicPreviewConfig.autoRefreshInterval, 10) || 0, breakpoints: MagicPreviewConfig.breakpoints || {}, + lexicon: MagicPreviewConfig.lexicon || {}, }; return _config; } + /** + * Returns a lexicon string by key, falling back to the key itself. + * @param {string} key - Lexicon key (e.g. 'preview_button') + * @returns {string} + */ + function lexicon(key) { + var lex = config().lexicon; + return (lex && lex[key]) ? lex[key] : key; + } + // ========================================================================= // Panel DOM management // ========================================================================= @@ -70,10 +82,10 @@ // Build breakpoint buttons HTML var bpKeys = [ - { key: 'full', label: 'Full' }, - { key: 'desktop', label: 'Desktop' }, - { key: 'tablet', label: 'Tablet' }, - { key: 'mobile', label: 'Mobile' } + { key: 'full', label: lexicon('bp_full') }, + { key: 'desktop', label: lexicon('bp_desktop') }, + { key: 'tablet', label: lexicon('bp_tablet') }, + { key: 'mobile', label: lexicon('bp_mobile') } ]; var bpHtml = ''; for (var i = 0; i < bpKeys.length; i++) { @@ -88,12 +100,12 @@ + '
' + '
' + bpHtml + '
' + '
' - + '' - + '
' + '
' + '
' - + '

Preparing your preview...

' + + '

' + lexicon('preparing_preview') + '

' + '
' + '
' - + '

Click Preview to generate a preview.

' + + '

' + lexicon('idle_message') + '

' + '
' + '
'; @@ -259,6 +271,7 @@ panelEl.classList.remove('mmmp-panel--open'); document.body.classList.remove('mmmp-panel-onpage-active'); syncActionButtonsOffset(); + stopAutoRefresh(); if (panelIframe) { panelIframe.src = ''; @@ -619,6 +632,45 @@ // Form submission: submits resource data to the preview processor // ========================================================================= + /** + * Syncs form data for a silent auto-refresh preview submission. + * + * Replicates the essential parts of the resource panel's beforeSubmit + * handler (RTE content sync, resource groups encoding) and collects + * third-party extra data (e.g. ContentBlocks) without firing the + * panel's 'save' event — which would cause side effects if triggered + * every few seconds. + * + * @param {Ext.form.BasicForm} fm - The resource form + * @param {MODx.panel.Resource} panel - The resource panel component + */ + function syncFormForPreview(fm, panel) { + // 1. Sync RTE content: copy textarea value to hiddenContent + var ta = Ext.get(panel.contentField); + if (ta) { + var hc = Ext.getCmp('hiddenContent'); + if (hc) { hc.setValue(ta.dom.value); } + } + + // 2. Sync RTE editors (TinyMCE, CKEditor, etc.) + if (panel.cleanupEditor) { + panel.cleanupEditor(); + } + + // 3. Encode resource groups into baseParams + var g = Ext.getCmp('modx-grid-resource-security'); + if (g) { + Ext.apply(fm.baseParams, { + resource_groups: g.encode() + }); + } + + // 4. ContentBlocks: inject block data if available + if (typeof ContentBlocks !== 'undefined' && ContentBlocks.getData) { + fm.baseParams['contentblocks'] = ContentBlocks.getData(); + } + } + /** * Submits the resource form to the MagicPreview processor. * @@ -626,45 +678,71 @@ * (waitMsg), tree refresh, dirty-state reset, and "Save successful" * status message that MODX's default success handler triggers. * - * Instead, we: - * 1. Fire the panel's beforeSubmit event (syncs RTE content, encodes - * resource groups — same as a normal save). - * 2. Submit the BasicForm directly to our connector with no waitMsg. - * 3. Handle the response in success/failure callbacks that never touch - * the FormPanel-level success event. + * For manual previews (button click, initial auto-preview), we fire + * the panel's beforeSubmit event so all extras can prepare their data. + * + * For silent auto-refreshes, we use syncFormForPreview() which + * replicates the essential data syncing without firing the 'save' + * event that beforeSubmit triggers at the end. + * + * @param {object} [options] Optional settings for this submission + * @param {boolean} [options.showLoading=true] Whether to show the loading + * animation. Set to false for background auto-refreshes. */ - function submitPreview() { + function submitPreview(options) { + options = options || {}; + var showLoading = options.showLoading !== false; + var panel = Ext.getCmp('modx-panel-resource'); if (!panel) return; var fm = panel.getForm(); if (!fm) return; - // Show loading state immediately - MagicPreview.showLoading(); - - // Validate form fields - var isValid = true; - if (fm.items && fm.items.items) { - for (var fld in fm.items.items) { - if (fm.items.items[fld] && fm.items.items[fld].validate) { - if (!fm.items.items[fld].validate()) { - fm.items.items[fld].markInvalid(); - isValid = false; + // Show loading state immediately (only for manual/initial previews) + if (showLoading) { + MagicPreview.showLoading(); + } + + // Mark a request as in-flight so auto-refresh doesn't overlap + submitInFlight = true; + + // Validate form fields (only for manual previews — skip for + // silent auto-refreshes to avoid marking fields invalid) + if (showLoading) { + var isValid = true; + if (fm.items && fm.items.items) { + for (var fld in fm.items.items) { + if (fm.items.items[fld] && fm.items.items[fld].validate) { + if (!fm.items.items[fld].validate()) { + fm.items.items[fld].markInvalid(); + isValid = false; + } } } } + if (!isValid) { + submitInFlight = false; + return; + } + } + + if (showLoading) { + // Manual preview: fire beforeSubmit so all extras can prepare + // their data (ContentBlocks, etc.) — same as a normal save. + var canSubmit = panel.fireEvent('beforeSubmit', { + form: fm, + options: {}, + config: panel.config + }); + if (canSubmit === false) { + submitInFlight = false; + return; + } + } else { + // Silent auto-refresh: sync form data without firing save event + syncFormForPreview(fm, panel); } - if (!isValid) return; - - // Fire beforeSubmit on the panel so RTE content is synced, - // resource groups are encoded, etc. — same as a normal save. - var canSubmit = panel.fireEvent('beforeSubmit', { - form: fm, - options: {}, - config: panel.config - }); - if (canSubmit === false) return; // Stash and swap the action + URL on the BasicForm so ExtJS // posts to our preview connector instead of the real save endpoint. @@ -682,19 +760,88 @@ success: function(form, action) { fm.baseParams['action'] = originalAction; fm.url = originalUrl; + submitInFlight = false; var result = action.result; if (result && result.object && result.object.preview_hash) { - MagicPreview.show(result.object.preview_hash); + var hash = result.object.preview_hash; + + // Only reload the iframe if the hash (and therefore + // the preview data) has actually changed. The server + // returns a deterministic hash based on the cached + // data, so identical content produces the same key. + if (hash !== lastPanelHash) { + MagicPreview.show(hash); + } } + + // (Re)start the auto-refresh timer after a successful preview + startAutoRefresh(); }, failure: function(form, action) { fm.baseParams['action'] = originalAction; fm.url = originalUrl; + submitInFlight = false; } }); } + // ========================================================================= + // Auto-refresh: periodically re-submits the form to detect changes + // ========================================================================= + + /** @type {boolean} Whether a preview submit request is currently in flight */ + var submitInFlight = false; + + /** @type {number|null} The setInterval ID for auto-refresh */ + var autoRefreshTimer = null; + + /** + * Starts the auto-refresh timer. If one is already running, it is + * restarted (reset). Only runs when the panel is open and the + * interval setting is > 0. + * + * The timer always re-submits the form to the preview processor. + * Change detection happens server-side: the processor returns a + * deterministic hash of the cached data, and the client skips + * reloading the iframe when the hash hasn't changed. This approach + * correctly captures data from all sources (TVs, ContentBlocks, + * and any extras that inject data via beforeSubmit / baseParams). + */ + function startAutoRefresh() { + stopAutoRefresh(); + + var interval = config().autoRefreshInterval; + if (interval <= 0) return; + if (config().previewMode !== 'panel') return; + if (!MagicPreview.isOpen()) return; + + autoRefreshTimer = setInterval(function() { + // Skip if a request is already in flight + if (submitInFlight) return; + + // Skip if the panel was closed in the meantime + if (!MagicPreview.isOpen()) { + stopAutoRefresh(); + return; + } + + // Always re-submit; the server returns a deterministic hash + // and the client skips the iframe reload when data is unchanged. + submitPreview({ showLoading: false }); + }, interval * 1000); + } + + /** + * Stops the auto-refresh timer. + */ + function stopAutoRefresh() { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + } + // ========================================================================= // ExtJS: Button injection // ========================================================================= @@ -724,8 +871,10 @@ getButtons: function(cfg) { var btns = this._originals.getButtons.call(this, cfg); var btnView = btns.map(function(btn) { return btn.id; }).indexOf('modx-abtn-preview'); + // If the View button doesn't exist, insert at the start of the array + if (btnView === -1) btnView = 0; btns.splice(btnView, 0, { - text: 'Preview', + text: lexicon('preview_button'), id: 'modx-abtn-real-preview', handler: function() { submitPreview(); }, scope: this diff --git a/core/components/magicpreview/controllers/preview.class.php b/core/components/magicpreview/controllers/preview.class.php index 5c95963..a9b6b3f 100644 --- a/core/components/magicpreview/controllers/preview.class.php +++ b/core/components/magicpreview/controllers/preview.class.php @@ -66,7 +66,7 @@ public function getPageTitle(): ?string */ public function loadCustomCssJs() { - $this->addJavascript($this->magicpreview->config['jsUrl'].'mgr/magicpreview.class.js'); + // Note: no custom JS file needed; the preview page is self-contained $this->addCss($this->magicpreview->config['cssUrl'].'preview.css'); $this->addHtml('