diff --git a/_bootstrap/index.php b/_bootstrap/index.php index 644564a..ce7ff60 100644 --- a/_bootstrap/index.php +++ b/_bootstrap/index.php @@ -111,6 +111,13 @@ ), array('pluginid','event'), false)) { echo "Error creating modPluginEvent.\n"; } + if (!createObject('modPluginEvent', array( + 'pluginid' => $vcPlugin->get('id'), + 'event' => 'OnManagerPageBeforeRender', + 'priority' => 0, + ), array('pluginid','event'), false)) { + echo "Error creating modPluginEvent.\n"; + } } diff --git a/_build/data/settings.php b/_build/data/settings.php index e92ddad..468e948 100644 --- a/_build/data/settings.php +++ b/_build/data/settings.php @@ -21,4 +21,24 @@ 'area' => 'Preview', 'value' => '' ], + 'preview_mode' => [ + 'area' => 'Preview', + 'value' => 'New Window', + 'xtype' => 'magicpreview-combo-preview-mode', + ], + 'panel_layout' => [ + 'area' => 'Preview', + 'value' => 'Overlay', + 'xtype' => 'magicpreview-combo-panel-layout', + ], + 'panel_extended' => [ + 'area' => 'Preview', + 'value' => false, + 'xtype' => 'combo-boolean', + ], + 'auto_refresh_interval' => [ + 'area' => 'Preview', + 'value' => '5', + 'xtype' => 'numberfield', + ], ]; \ No newline at end of file diff --git a/_build/events/events.magicpreview.php b/_build/events/events.magicpreview.php index 97455f7..7d7018c 100644 --- a/_build/events/events.magicpreview.php +++ b/_build/events/events.magicpreview.php @@ -5,6 +5,7 @@ $e = array( 'OnDocFormRender', 'OnLoadWebDocument', + 'OnManagerPageBeforeRender', ); foreach ($e as $ev) { diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index af85730..c0ed9ed 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; } @@ -7,7 +15,7 @@ border-top-left-radius: 0 !important; border-bottom-left-radius: 0 !important; } - + #modx-abtn-preview:before { content: "↗"; } @@ -16,3 +24,302 @@ 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%; + flex-direction: column; + background: #f5f5f5; +} + +/* -------------------------------------------------------------------------- + Preview Panel: overlay layout + Panel slides in from the right and floats over the editor content. + -------------------------------------------------------------------------- */ + +.mmmp-panel--overlay { + z-index: 12000; + 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; + position: relative; + overflow: hidden; +} + +.mmmp-panel__iframe { + width: 100%; + height: 100%; + border: none; + display: block; + background: #fff; + position: absolute; + top: 0; + left: 0; +} + +.mmmp-panel__iframe--staging { + visibility: hidden; +} + +.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/combo.js b/assets/components/magicpreview/js/combo.js new file mode 100644 index 0000000..de4020f --- /dev/null +++ b/assets/components/magicpreview/js/combo.js @@ -0,0 +1,67 @@ +/** + * MagicPreview - Combo xtypes for system settings + * + * Registers custom ExtJS combo components so the preview_mode and + * panel_layout system settings render as dropdowns instead of plain + * text fields in the MODX manager. + * + * Loaded on settings pages via OnManagerPageBeforeRender. + */ +MagicPreview = window.MagicPreview || {}; +MagicPreview.combo = MagicPreview.combo || {}; + +/** + * Preview Mode combo: New Window or Panel. + */ +MagicPreview.combo.PreviewMode = function(config) { + config = config || {}; + Ext.applyIf(config, { + store: new Ext.data.SimpleStore({ + fields: ['v'], + data: [ + ['New Window'], + ['Panel'] + ] + }), + displayField: 'v', + valueField: 'v', + mode: 'local', + triggerAction: 'all', + editable: false, + selectOnFocus: false, + preventRender: true, + forceSelection: true, + enableKeyEvents: true + }); + MagicPreview.combo.PreviewMode.superclass.constructor.call(this, config); +}; +Ext.extend(MagicPreview.combo.PreviewMode, MODx.combo.ComboBox); +Ext.reg('magicpreview-combo-preview-mode', MagicPreview.combo.PreviewMode); + +/** + * Panel Layout combo: Overlay or On Page. + */ +MagicPreview.combo.PanelLayout = function(config) { + config = config || {}; + Ext.applyIf(config, { + store: new Ext.data.SimpleStore({ + fields: ['v'], + data: [ + ['Overlay'], + ['On Page'] + ] + }), + displayField: 'v', + valueField: 'v', + mode: 'local', + triggerAction: 'all', + editable: false, + selectOnFocus: false, + preventRender: true, + forceSelection: true, + enableKeyEvents: true + }); + MagicPreview.combo.PanelLayout.superclass.constructor.call(this, config); +}; +Ext.extend(MagicPreview.combo.PanelLayout, MODx.combo.ComboBox); +Ext.reg('magicpreview-combo-panel-layout', MagicPreview.combo.PanelLayout); diff --git a/assets/components/magicpreview/js/panel.js b/assets/components/magicpreview/js/panel.js new file mode 100644 index 0000000..bc0aa43 --- /dev/null +++ b/assets/components/magicpreview/js/panel.js @@ -0,0 +1,582 @@ +/** + * MagicPreview - Panel module + * + * Manages the inline side panel: DOM creation, open/close lifecycle, + * double-buffered iframe swap with scroll preservation, breakpoint + * controls, drag-to-resize, ExtJS Viewport layout override, and + * action buttons offset. + * + * Exposes its interface on MagicPreview._panel so the orchestrator + * (preview.js) can delegate to it. + * + * Must be initialised via init(cfg) before any other method is called. + * The config object is provided by the orchestrator from the lazy-loaded + * PHP-injected globals. + */ +(function() { + window.MagicPreview = window.MagicPreview || {}; + + // ========================================================================= + // Configuration (set via init) + // ========================================================================= + + /** @type {string} Setting value for overlay layout */ + var LAYOUT_OVERLAY = 'Overlay'; + /** @type {string} Setting value for on-page layout */ + var LAYOUT_ONPAGE = 'On Page'; + + /** @type {object|null} */ + var _cfg = null; + + /** + * Returns a CSS-safe slug from a setting value. + * e.g. 'On Page' -> 'onpage', 'Overlay' -> 'overlay' + * @param {string} value + * @returns {string} + */ + function cssSlug(value) { + return value.toLowerCase().replace(/\s+/g, ''); + } + + /** + * Returns a lexicon string by key, falling back to the key itself. + * @param {string} key + * @returns {string} + */ + function lexicon(key) { + var lex = _cfg && _cfg.lexicon; + return (lex && lex[key]) ? lex[key] : key; + } + + // ========================================================================= + // Panel DOM management + // ========================================================================= + + /** @type {HTMLElement|null} */ + var panelEl = null; + /** @type {HTMLIFrameElement|null} The currently visible iframe */ + var panelIframeA = null; + /** @type {HTMLIFrameElement|null} The off-screen staging iframe */ + var panelIframeB = 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 lastHash = 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; + + panelEl = document.createElement('div'); + panelEl.id = 'mmmp-panel'; + panelEl.className = 'mmmp-panel mmmp-panel--' + cssSlug(_cfg.panelLayout); + + // Build breakpoint buttons HTML + var bpKeys = [ + { 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++) { + var bp = bpKeys[i]; + var active = bp.key === 'full' ? ' mmmp-panel__bp-btn--active' : ''; + bpHtml += ''; + } + + panelEl.innerHTML = '' + + '
' + + '
' + bpHtml + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '

' + lexicon('preparing_preview') + '

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

' + lexicon('idle_message') + '

' + + '
' + + '
'; + + document.body.appendChild(panelEl); + + panelIframeA = document.getElementById('mmmp-panel-iframe-a'); + panelIframeB = document.getElementById('mmmp-panel-iframe-b'); + 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: delegates to the orchestrator's onReload callback + panelEl.querySelector('.mmmp-panel__reload').addEventListener('click', function() { + if (_cfg.onReload) _cfg.onReload(); + }); + + // 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 frameWrapper = panelEl.querySelector('.mmmp-panel__frame-wrapper'); + var width; + + switch (bp) { + case 'desktop': + width = (_cfg.breakpoints && _cfg.breakpoints.desktop) || '1280px'; + break; + case 'tablet': + width = (_cfg.breakpoints && _cfg.breakpoints.tablet) || '768px'; + break; + case 'mobile': + width = (_cfg.breakpoints && _cfg.breakpoints.mobile) || '320px'; + break; + default: + width = '100%'; + } + + frameWrapper.style.width = width; + } + + /** + * Shows the loading state in the panel. + */ + function showLoading() { + if (!panelIframeA || !panelLoading) return; + panelIframeA.parentElement.style.display = 'none'; + panelLoading.style.display = 'flex'; + if (panelIdle) panelIdle.style.display = 'none'; + panelIframeA.src = ''; + panelIframeB.src = ''; + + // Reset iframe roles to a known state + panelIframeA.className = 'mmmp-panel__iframe mmmp-panel__iframe--active'; + panelIframeB.className = 'mmmp-panel__iframe mmmp-panel__iframe--staging'; + } + + /** + * Shows the idle/placeholder state in the panel (no preview yet). + */ + function showIdle() { + if (!panelIframeA || !panelIdle) return; + panelIframeA.parentElement.style.display = 'none'; + panelLoading.style.display = 'none'; + panelIdle.style.display = 'flex'; + panelIframeA.src = ''; + panelIframeB.src = ''; + + // Reset iframe roles to a known state + panelIframeA.className = 'mmmp-panel__iframe mmmp-panel__iframe--active'; + panelIframeB.className = 'mmmp-panel__iframe mmmp-panel__iframe--staging'; + } + + /** + * Loads a preview URL into the panel using a double-buffered iframe + * swap to avoid any visual flash or scroll-jump. + * + * The new URL is loaded into the hidden "staging" iframe. Once its + * load event fires, the previous scroll position is restored, then + * the staging iframe is swapped to active (visible) and the old + * active iframe becomes the new staging (hidden). The user sees + * the old content the entire time, then an instant cut to the new + * content already at the correct scroll position. + * + * @param {string} url - The full preview URL to load into the iframe + */ + function showPreview(url) { + if (!panelIframeA || !panelIframeB || !panelLoading) return; + panelLoading.style.display = 'none'; + if (panelIdle) panelIdle.style.display = 'none'; + + // Determine which iframe is currently active (visible) and + // which is staging (hidden). Active has the --active class. + var active = panelIframeA.classList.contains('mmmp-panel__iframe--active') + ? panelIframeA : panelIframeB; + var staging = (active === panelIframeA) ? panelIframeB : panelIframeA; + + // Make sure the frame wrapper is visible + active.parentElement.style.display = 'block'; + + // Capture the current scroll position from the active iframe. + var scrollX = 0, scrollY = 0; + try { + if (active.contentWindow) { + scrollX = active.contentWindow.scrollX || 0; + scrollY = active.contentWindow.scrollY || 0; + } + } catch (e) { /* cross-origin or no document — use 0,0 */ } + + // Register the load handler before setting src to avoid a race + // condition where a cached response fires the event synchronously. + var onLoad = function() { + staging.removeEventListener('load', onLoad); + + // Restore scroll position in the staging iframe before + // making it visible, so there is no flash. + try { + staging.contentWindow.scrollTo(scrollX, scrollY); + } catch (e) { /* ignore */ } + + // Swap: staging becomes active, active becomes staging. + staging.classList.remove('mmmp-panel__iframe--staging'); + staging.classList.add('mmmp-panel__iframe--active'); + active.classList.remove('mmmp-panel__iframe--active'); + active.classList.add('mmmp-panel__iframe--staging'); + + // Clear the old iframe so it doesn't consume resources + // in the background. + active.src = ''; + }; + staging.addEventListener('load', onLoad); + + // Load the new preview into the staging (hidden) iframe. + staging.src = url; + } + + // ========================================================================= + // Panel open / close lifecycle + // ========================================================================= + + /** @type {boolean} Whether the window resize handler has been registered */ + var resizeHandlerRegistered = false; + + /** + * Opens the panel and applies the layout mode. + */ + function open() { + createPanel(); + + // Restore previously set custom width from drag-resize + if (customPanelWidth) { + panelEl.style.width = customPanelWidth + 'px'; + } + + panelEl.classList.add('mmmp-panel--open'); + + if (_cfg.panelLayout === LAYOUT_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 close() { + if (!panelEl) return; + panelEl.classList.remove('mmmp-panel--open'); + document.body.classList.remove('mmmp-panel-onpage-active'); + syncActionButtonsOffset(); + + if (panelIframeA) { + panelIframeA.src = ''; + } + if (panelIframeB) { + panelIframeB.src = ''; + } + lastHash = null; + + relayoutModx(); + } + + /** + * Check if the panel is currently open/visible. + * @returns {boolean} + */ + function isOpen() { + return panelEl !== null && panelEl.classList.contains('mmmp-panel--open'); + } + + /** + * 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 (panelIframeA) panelIframeA.style.pointerEvents = 'none'; + if (panelIframeB) panelIframeB.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 (panelIframeA) panelIframeA.style.pointerEvents = ''; + if (panelIframeB) panelIframeB.style.pointerEvents = ''; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + customPanelWidth = panelEl.offsetWidth; + + // For onpage mode, recalculate the ExtJS layout + if (_cfg.panelLayout === LAYOUT_ONPAGE && document.body.classList.contains('mmmp-panel-onpage-active')) { + relayoutModx(); + } + } + } + + // ========================================================================= + // Action buttons offset + ExtJS Viewport layout + // ========================================================================= + + /** + * 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 (_cfg.panelLayout === LAYOUT_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 panelIsOpen = document.body.classList.contains('mmmp-panel-onpage-active'); + + if (panelIsOpen) { + // 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 (panelIsOpen) { + var pw = getPanelWidth(); + layout.el.getViewSize = function() { + return { + width: window.innerWidth - pw, + height: window.innerHeight + }; + }; + } + layout.doLayout(); + }, 50); + } + + // ========================================================================= + // Onpage panel initialisation + // ========================================================================= + + /** + * For "onpage" layout with panel_extended: 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 panel_extended is off, the panel stays hidden until the user + * clicks Preview — open() handles everything at that point. + */ + function initOnpage() { + if (_cfg.panelLayout !== LAYOUT_ONPAGE) return; + if (!_cfg.panelExtended) return; + + createPanel(); + panelEl.classList.add('mmmp-panel--open'); + document.body.classList.add('mmmp-panel-onpage-active'); + showLoading(); + + 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(); + } + }); + } + + // ========================================================================= + // Internal API (consumed by the orchestrator in preview.js) + // ========================================================================= + + window.MagicPreview._panel = { + /** + * Initialise the panel module with configuration. + * Must be called once before any other method. + * @param {object} cfg + * @param {string} cfg.panelLayout - LAYOUT_OVERLAY or LAYOUT_ONPAGE + * @param {boolean} cfg.panelExtended - Start with panel open + * @param {object} cfg.breakpoints - {desktop, tablet, mobile} + * @param {object} cfg.lexicon - Lexicon strings + * @param {Function} cfg.onReload - Callback for reload button + */ + init: function(cfg) { + _cfg = cfg; + }, + + open: open, + close: close, + isOpen: isOpen, + showLoading: showLoading, + showIdle: showIdle, + showPreview: showPreview, + initOnpage: initOnpage, + getPanelWidth: getPanelWidth, + + /** + * Get the last preview hash that was loaded. + * Used by the orchestrator to skip duplicate iframe reloads. + * @returns {string|null} + */ + getLastHash: function() { return lastHash; }, + + /** + * Set the last preview hash. + * @param {string|null} hash + */ + setLastHash: function(hash) { lastHash = hash; } + }; +})(); diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index a6d2748..b5bc202 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -1,111 +1,486 @@ +/** + * MagicPreview - Orchestrator + * + * Thin orchestration layer that wires together the window module (_window) + * and panel module (_panel). Owns: lazy config resolution, the public + * window.MagicPreview API, form submission (manual + silent auto-refresh), + * auto-refresh timer, auto-preview on page load, and Preview button + * injection into the MODX manager action bar. + * + * Load order: window.js -> panel.js -> preview.js (this file, last) + * + * @global {object} MagicPreviewConfig - Injected by PHP plugin + * @global {number} MagicPreviewResource - Injected by PHP plugin + */ (function() { - Ext.onReady(function() { - var previewUrl = MODx.config.manager_url - + '?namespace=magicpreview&a=preview&resource=' + MagicPreviewResource, - 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 - switch ('object') { - case typeof Articles: - basePage = Articles.page.UpdateArticle ?? basePage; - break; - case typeof collections: - basePage = collections.page.UpdateCategory ?? basePage; - basePage = collections.page.UpdateSelection ?? basePage; - break; - case typeof LocationResources: - basePage = LocationResources.page.UpdateLocation ?? basePage; - break; + // ========================================================================= + // Setting value constants + // ========================================================================= + + /** @type {string} Setting value for panel preview mode */ + var MODE_PANEL = 'Panel'; + /** @type {string} Setting value for new window preview mode */ + var MODE_WINDOW = 'New Window'; + /** @type {string} Setting value for overlay panel layout */ + var LAYOUT_OVERLAY = 'Overlay'; + + // ========================================================================= + // Configuration (lazy-loaded: resolved on first access because the + // globals MagicPreviewConfig, MagicPreviewResource and MODx.config may + // not exist yet when this script is parsed from ) + // ========================================================================= + + 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 ?? MODE_WINDOW, + panelLayout: MagicPreviewConfig.panelLayout ?? LAYOUT_OVERLAY, + panelExtended: !!MagicPreviewConfig.panelExtended, + 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 + * @returns {string} + */ + function lexicon(key) { + var lex = config().lexicon; + return (lex && lex[key]) ? lex[key] : key; + } + + // ========================================================================= + // References to sub-modules (set by window.js and panel.js before us) + // ========================================================================= + + var _window = window.MagicPreview._window; + var _panel = window.MagicPreview._panel; + + // ========================================================================= + // Panel helper: build the full preview URL from a hash + // ========================================================================= + + /** + * @param {string} hash + * @returns {string} + */ + function previewFrameUrl(hash) { + var c = config(); + return c.baseFrameUrl + c.frameJoiner + 'show_preview=' + hash; + } + + // ========================================================================= + // Public API: window.MagicPreview + // ========================================================================= + + /** + * Show a preview for the given hash. Opens the 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) { + var c = config(); + + if (c.previewMode === MODE_PANEL) { + _panel.open(); + if (!hash || hash === 'loading') { + _panel.showLoading(); + } else { + _panel.setLastHash(hash); + _panel.showPreview(previewFrameUrl(hash)); + } + } else { + _window.open(c.previewUrl, MagicPreviewResource); + if (hash && hash !== 'loading') { + _window.show(c.previewUrl, hash); + } } + }; - Ext.override(basePage, { - _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"); - btns.splice(btnView, 0, { - text: 'Preview', - id: 'modx-abtn-real-preview', - handler: this.mpPreview, - scope: this - }); - return btns; - }, + /** + * Close the preview (window or panel). + */ + window.MagicPreview.close = function() { + if (config().previewMode === MODE_PANEL) { + _panel.close(); + stopAutoRefresh(); + } else { + _window.close(); + } + }; - /** - * 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 the preview is currently open/visible. + * @returns {boolean} + */ + window.MagicPreview.isOpen = function() { + if (config().previewMode === MODE_PANEL) { + return _panel.isOpen(); + } + return _window.isOpen(); + }; + + /** + * Get the current preview mode. + * @returns {string} MODE_WINDOW or MODE_PANEL + */ + window.MagicPreview.getMode = function() { + return config().previewMode; + }; + + /** + * Get the 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 preview window reference. Returns null if in panel mode or + * if no window is open. + * @returns {Window|null} + */ + window.MagicPreview.getWindow = function() { + return _window.getWindow(); + }; + + /** + * Open/focus the 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 + // ========================================================================= + + /** @type {boolean} Whether a preview submit request is currently in flight */ + var submitInFlight = false; + + /** + * 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. + * + * 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. + * + * 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(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 (only for manual/initial previews) + if (showLoading) { + MagicPreview.showLoading(); + } + + // Mark a request as in-flight so auto-refresh doesn't overlap + submitInFlight = true; - // 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; - } + // 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 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 (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 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; + // 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'; - if (r.result && r.result.object && r.result.object.preview_hash) { - o.previewWindow.location = previewUrl + '#' + r.result.object.preview_hash; - } + 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; + submitInFlight = false; + + // If the panel was closed while the request was in flight, + // discard the result to avoid re-opening the panel. + if (config().previewMode === MODE_PANEL && !MagicPreview.isOpen()) { + return; + } - }, this); + var result = action.result; + if (result && result.object && result.object.preview_hash) { + var hash = result.object.preview_hash; - // Submit the form to the processor - o.form.submit({ - headers: { - 'Powered-By': 'MODx' - ,'modAuth': MODx.siteId + // Only reload the iframe if the hash (and therefore + // the preview data) has actually changed. + if (config().previewMode === MODE_PANEL) { + if (hash !== _panel.getLastHash()) { + MagicPreview.show(hash); } - }); + } else { + 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 {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. + */ + function startAutoRefresh() { + stopAutoRefresh(); + + var interval = config().autoRefreshInterval; + if (interval <= 0) return; + if (config().previewMode !== MODE_PANEL) return; + if (!MagicPreview.isOpen()) return; + + autoRefreshTimer = setInterval(function() { + if (submitInFlight) return; + + if (!MagicPreview.isOpen()) { + stopAutoRefresh(); + return; + } + + submitPreview({ showLoading: false }); + }, interval * 1000); + } + + /** + * Stops the auto-refresh timer. + */ + function stopAutoRefresh() { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = null; + } + } + + // ========================================================================= + // Auto-preview on page load + // ========================================================================= + + /** + * Triggers an initial preview by submitting the form after the + * resource panel has finished rendering. Only runs when panelExtended + * is enabled and previewMode is MODE_PANEL. + */ + function initAutoPreview() { + if (!config().panelExtended) return; + if (config().previewMode !== MODE_PANEL) return; + + // For overlay mode, open the panel first (onpage is already open + // via _panel.initOnpage) + if (config().panelLayout === LAYOUT_OVERLAY) { + _panel.open(); + _panel.showLoading(); + } + + // Wait for the resource panel to be available, then submit. + 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); + } + + // ========================================================================= + // Panel module initialisation + // ========================================================================= + + /** + * Initialises the panel module with config from the lazy-loaded + * PHP globals. Must be called inside Ext.onReady so the globals + * are guaranteed to exist. + */ + function initPanelModule() { + var c = config(); + _panel.init({ + panelLayout: c.panelLayout, + panelExtended: c.panelExtended, + breakpoints: c.breakpoints, + lexicon: c.lexicon, + onReload: function() { + submitPreview(); + } + }); + } + + // ========================================================================= + // ExtJS: Button injection + // ========================================================================= + + Ext.onReady(function() { + // Initialise the panel sub-module with config + initPanelModule(); + + // Override getButtons on the base UpdateResource prototype. Extras + // like Collections, Articles, and LocationResources extend this class + // without overriding getButtons, so patching the base covers them all. + // If another extra also wraps getButtons via Ext.override(), the + // wrap-and-delegate pattern ensures both run regardless of load order. + Ext.override(MODx.page.UpdateResource, { + _mpOrigGetButtons: MODx.page.UpdateResource.prototype.getButtons, + getButtons: function(cfg) { + var btns = this._mpOrigGetButtons.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 + if (btnView === -1) btnView = 0; + btns.splice(btnView, 0, { + text: lexicon('preview_button'), + id: 'modx-abtn-real-preview', + handler: function() { submitPreview(); }, + scope: this + }); + return btns; }, // 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. + _panel.initOnpage(); + + // Auto-preview: submit the form immediately to generate a preview + initAutoPreview(); }); })(); - diff --git a/assets/components/magicpreview/js/window.js b/assets/components/magicpreview/js/window.js new file mode 100644 index 0000000..067af77 --- /dev/null +++ b/assets/components/magicpreview/js/window.js @@ -0,0 +1,83 @@ +/** + * MagicPreview - Window module + * + * Manages the preview window lifecycle: open, navigate, close. + * Exposes its interface on MagicPreview._window so the orchestrator + * (preview.js) can delegate to it. + * + * This module has no dependencies on config — the orchestrator passes + * the required URLs directly as arguments. + */ +(function() { + window.MagicPreview = window.MagicPreview || {}; + + /** @type {Window|null} */ + var previewWindow = null; + + /** + * Opens or focuses the preview window with a loading state. + * @param {string} previewUrl - The manager preview controller URL + * @param {number} resourceId - The resource ID (for unique window name) + */ + function open(previewUrl, resourceId) { + if (previewWindow && !previewWindow.closed) { + previewWindow.focus(); + } else { + previewWindow = window.open(previewUrl + '#loading', 'MagicPreview_' + resourceId); + previewWindow.opener = window; + } + } + + /** + * Navigates the preview window to the given preview hash. + * @param {string} previewUrl - The manager preview controller URL + * @param {string} hash - The preview hash from the processor + */ + function show(previewUrl, hash) { + if (previewWindow && !previewWindow.closed) { + previewWindow.location = previewUrl + '#' + hash; + } + } + + /** + * Closes the preview window. + */ + function close() { + if (previewWindow && !previewWindow.closed) { + previewWindow.close(); + } + previewWindow = null; + } + + /** + * Check if the preview window is currently open. + * @returns {boolean} + */ + function isOpen() { + return previewWindow !== null && !previewWindow.closed; + } + + /** + * Get the preview window reference (for consumers that need to set + * custom location with additional query params, e.g. VersionX). + * @returns {Window|null} + */ + function getWindow() { + if (previewWindow && !previewWindow.closed) { + return previewWindow; + } + return null; + } + + // ------------------------------------------------------------------------- + // Internal API (consumed by the orchestrator in preview.js) + // ------------------------------------------------------------------------- + + window.MagicPreview._window = { + open: open, + show: show, + close: close, + isOpen: isOpen, + getWindow: getWindow + }; +})(); diff --git a/core/components/magicpreview/controllers/preview.class.php b/core/components/magicpreview/controllers/preview.class.php index 5c95963..8d72859 100644 --- a/core/components/magicpreview/controllers/preview.class.php +++ b/core/components/magicpreview/controllers/preview.class.php @@ -46,7 +46,7 @@ public function process(array $scriptProperties = array()) * Defines the lexicon topics to load in our controller. * @return array */ - public function getLanguageTopics(): array + public function getLanguageTopics() { return ['magicpreview:default']; } @@ -55,7 +55,7 @@ public function getLanguageTopics(): array * The pagetitle to put in the attribute. * @return null|string */ - public function getPageTitle(): ?string + public function getPageTitle() { return $this->modx->lexicon('magicpreview'); } @@ -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('<script type="text/javascript"> Ext.onReady(function() { @@ -80,7 +80,7 @@ public function loadCustomCssJs() * The name for the template file to load. * @return string */ - public function getTemplateFile(): string + public function getTemplateFile() { $custom = $this->modx->getOption('magicpreview.custom_preview_tpl'); $tplPath = $this->magicpreview->config['templatesPath']; diff --git a/core/components/magicpreview/docs/changelog.txt b/core/components/magicpreview/docs/changelog.txt index f4c6688..39466a2 100644 --- a/core/components/magicpreview/docs/changelog.txt +++ b/core/components/magicpreview/docs/changelog.txt @@ -1,3 +1,16 @@ +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.panel_extended setting to start with the preview panel open on page load +- Add magicpreview.auto_refresh_interval setting for automatic preview refresh when form data changes +- 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..580ac63 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -2,6 +2,14 @@ /** * @var modX $modx */ +// Setting value constants +if (!defined('MAGICPREVIEW_MODE_PANEL')) { + define('MAGICPREVIEW_MODE_PANEL', 'Panel'); + define('MAGICPREVIEW_MODE_WINDOW', 'New Window'); + define('MAGICPREVIEW_LAYOUT_OVERLAY', 'Overlay'); + define('MAGICPREVIEW_LAYOUT_ONPAGE', 'On Page'); +} + $path = $modx->getOption('magicpreview.core_path', null, $modx->getOption('core_path') . 'components/magicpreview/'); $service = $modx->getService('magicpreview', 'MagicPreview', $path . '/model/magicpreview/'); if (!($service instanceof MagicPreview)) { @@ -19,7 +27,46 @@ $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, MAGICPREVIEW_MODE_WINDOW); + $jsConfig['panelLayout'] = $modx->getOption('magicpreview.panel_layout', null, MAGICPREVIEW_LAYOUT_OVERLAY); + $jsConfig['panelExtended'] = (bool)$modx->getOption('magicpreview.panel_extended', null, false); + $jsConfig['autoRefreshInterval'] = (int)$modx->getOption('magicpreview.auto_refresh_interval', null, 5); + $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'), + ]; + $jsConfig['lexicon'] = [ + 'preview_button' => $modx->lexicon('magicpreview.preview_button'), + 'preparing_preview' => $modx->lexicon('magicpreview.preparing_preview'), + 'idle_message' => $modx->lexicon('magicpreview.idle_message'), + 'reload_preview' => $modx->lexicon('magicpreview.reload_preview'), + 'close_panel' => $modx->lexicon('magicpreview.close_panel'), + 'bp_full' => $modx->lexicon('magicpreview.bp_full'), + 'bp_desktop' => $modx->lexicon('magicpreview.bp_desktop'), + 'bp_tablet' => $modx->lexicon('magicpreview.bp_tablet'), + 'bp_mobile' => $modx->lexicon('magicpreview.bp_mobile'), + ]; + + $modx->controller->addJavascript($service->config['assetsUrl'] . 'js/window.js?v=' . $service::VERSION); + $modx->controller->addJavascript($service->config['assetsUrl'] . 'js/panel.js?v=' . $service::VERSION); $modx->controller->addJavascript($service->config['assetsUrl'] . 'js/preview.js?v=' . $service::VERSION); + + // When onpage panel + auto-preview is active, the panel will be + // visible immediately on page load. Inject an early CSS rule so + // the action buttons bar starts at the correct offset instead of + // flashing at full width before JS runs syncActionButtonsOffset(). + $earlyPanelCss = ''; + if ($jsConfig['previewMode'] === MAGICPREVIEW_MODE_PANEL && $jsConfig['panelLayout'] === MAGICPREVIEW_LAYOUT_ONPAGE && $jsConfig['panelExtended']) { + $earlyPanelCss = '<style>.mmmp-panel-onpage-active #modx-action-buttons { right: 40%; }</style>'; + } + $modx->controller->addHtml(' <script> Ext.onReady(() => { @@ -31,14 +78,29 @@ type="text/css" href="' . $service->config['assetsUrl'] . 'css/mgr.css?v=' . $service::VERSION . '" /> + ' . $earlyPanelCss . ' <script> - MagicPreviewConfig = ' . json_encode($service->config) . '; + MagicPreviewConfig = ' . json_encode($jsConfig) . '; MagicPreviewResource = ' . $resource->get('id') . '; </script> '); } break; + case 'OnManagerPageBeforeRender': + // Load combo xtypes for system settings dropdowns. Only needed + // on pages that render a settings grid. + $settingsActions = [ + 'system/settings', + 'context/update', + 'security/usergroup/update', + 'security/user/update', + ]; + if (in_array($modx->request->action, $settingsActions, true)) { + $modx->controller->addJavascript($service->config['assetsUrl'] . 'js/combo.js?v=' . $service::VERSION); + } + break; + case 'OnLoadWebDocument': if (!array_key_exists('show_preview', $_GET)) { return; diff --git a/core/components/magicpreview/lexicon/da/default.inc.php b/core/components/magicpreview/lexicon/da/default.inc.php index 03bfca0..582656c 100644 --- a/core/components/magicpreview/lexicon/da/default.inc.php +++ b/core/components/magicpreview/lexicon/da/default.inc.php @@ -2,6 +2,10 @@ $_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.reload_preview'] = 'Genindlæs forhåndsvisning'; +$_lang['magicpreview.preview_button'] = 'Forhåndsvisning'; +$_lang['magicpreview.idle_message'] = 'Klik på <strong>Forhåndsvisning</strong> for at generere en forhåndsvisning.'; $_lang['magicpreview.bp_full'] = 'Fuld størrelse'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; @@ -9,13 +13,21 @@ $_lang['magicpreview.bp_mobile'] = 'Mobil'; // Settings -$_lang['setting_magicpreview.breakpoint_desktop'] = 'Breakpoint - Desktop Width'; -$_lang['setting_magicpreview.breakpoint_desktop_desc'] = 'Desktop breakpoint width in pixels. Default: 1280px'; -$_lang['setting_magicpreview.breakpoint_tablet'] = 'Breakpoint - Tablet Width'; -$_lang['setting_magicpreview.breakpoint_tablet_desc'] = 'Tablet breakpoint width in pixels. Default: 768px'; -$_lang['setting_magicpreview.breakpoint_mobile'] = 'Breakpoint - Mobile Width'; -$_lang['setting_magicpreview.breakpoint_mobile_desc'] = 'Mobile breakpoint width in pixels. Default: 320px'; -$_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.breakpoint_desktop'] = 'Breakpoint - Desktop-bredde'; +$_lang['setting_magicpreview.breakpoint_desktop_desc'] = 'Desktop breakpoint-bredde i pixels. Standard: 1280px'; +$_lang['setting_magicpreview.breakpoint_tablet'] = 'Breakpoint - Tablet-bredde'; +$_lang['setting_magicpreview.breakpoint_tablet_desc'] = 'Tablet breakpoint-bredde i pixels. Standard: 768px'; +$_lang['setting_magicpreview.breakpoint_mobile'] = 'Breakpoint - Mobil-bredde'; +$_lang['setting_magicpreview.breakpoint_mobile_desc'] = 'Mobil breakpoint-bredde i pixels. Standard: 320px'; +$_lang['setting_magicpreview.custom_preview_tpl'] = 'Brugerdefineret forhåndsvisningsskabelon'; +$_lang['setting_magicpreview.custom_preview_tpl_desc'] = 'Stien til den skabelonfil, der skal indlæses.'; +$_lang['setting_magicpreview.custom_preview_css'] = 'Brugerdefineret forhåndsvisnings-CSS'; +$_lang['setting_magicpreview.custom_preview_css_desc'] = 'Stien til den CSS-fil, der skal indlæses.'; +$_lang['setting_magicpreview.preview_mode'] = 'Forhåndsvisningstilstand'; +$_lang['setting_magicpreview.preview_mode_desc'] = 'Hvordan forhåndsvisningen vises. "New Window" å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). "On Page" formindsker editoren for en permanent kolonne.'; +$_lang['setting_magicpreview.panel_extended'] = 'Panel udvidet'; +$_lang['setting_magicpreview.panel_extended_desc'] = 'Start med forhåndsvisningspanelet udvidet, når ressourceformularen indlæses. Genererer automatisk en indledende forhåndsvisning. Gælder kun når forhåndsvisningstilstanden er sat til "Panel". Standard: Nej.'; +$_lang['setting_magicpreview.auto_refresh_interval'] = 'Automatisk opdateringsinterval'; +$_lang['setting_magicpreview.auto_refresh_interval_desc'] = 'Sekunder mellem automatiske forhåndsvisningsopdateringer, mens panelet er åbent. Forhåndsvisningen opdateres kun, når formulardata er ændret. Sæt til 0 for at deaktivere. Standard: 5.'; \ 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..0c30063 100644 --- a/core/components/magicpreview/lexicon/de/default.inc.php +++ b/core/components/magicpreview/lexicon/de/default.inc.php @@ -2,19 +2,31 @@ $_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.reload_preview'] = 'Vorschau neu laden'; +$_lang['magicpreview.preview_button'] = 'Vorschau'; +$_lang['magicpreview.idle_message'] = 'Klicken Sie auf <strong>Vorschau</strong>, um eine Vorschau zu erstellen.'; $_lang['magicpreview.bp_full'] = 'Volle Breite'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; $_lang['magicpreview.bp_tablet'] = 'Tablet'; $_lang['magicpreview.bp_mobile'] = 'Mobil'; // Settings -$_lang['setting_magicpreview.breakpoint_desktop'] = 'Breakpoint - Desktop Width'; -$_lang['setting_magicpreview.breakpoint_desktop_desc'] = 'Desktop breakpoint width in pixels. Default: 1280px'; -$_lang['setting_magicpreview.breakpoint_tablet'] = 'Breakpoint - Tablet Width'; -$_lang['setting_magicpreview.breakpoint_tablet_desc'] = 'Tablet breakpoint width in pixels. Default: 768px'; -$_lang['setting_magicpreview.breakpoint_mobile'] = 'Breakpoint - Mobile Width'; -$_lang['setting_magicpreview.breakpoint_mobile_desc'] = 'Mobile breakpoint width in pixels. Default: 320px'; +$_lang['setting_magicpreview.breakpoint_desktop'] = 'Breakpoint - Desktop-Breite'; +$_lang['setting_magicpreview.breakpoint_desktop_desc'] = 'Desktop Breakpoint-Breite in Pixeln. Standard: 1280px'; +$_lang['setting_magicpreview.breakpoint_tablet'] = 'Breakpoint - Tablet-Breite'; +$_lang['setting_magicpreview.breakpoint_tablet_desc'] = 'Tablet Breakpoint-Breite in Pixeln. Standard: 768px'; +$_lang['setting_magicpreview.breakpoint_mobile'] = 'Breakpoint - Mobil-Breite'; +$_lang['setting_magicpreview.breakpoint_mobile_desc'] = 'Mobil Breakpoint-Breite in Pixeln. Standard: 320px'; $_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. "New Window" ö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). "On Page" verkleinert den Editor für eine permanente Spalte.'; +$_lang['setting_magicpreview.panel_extended'] = 'Panel ausgeklappt'; +$_lang['setting_magicpreview.panel_extended_desc'] = 'Mit ausgeklapptem Vorschau-Panel starten, wenn das Ressourcen-Formular geladen wird. Generiert automatisch eine erste Vorschau. Gilt nur wenn der Vorschau-Modus auf "Panel" eingestellt ist. Standard: Nein.'; +$_lang['setting_magicpreview.auto_refresh_interval'] = 'Automatisches Aktualisierungsintervall'; +$_lang['setting_magicpreview.auto_refresh_interval_desc'] = 'Sekunden zwischen automatischen Vorschau-Aktualisierungen, während das Panel geöffnet ist. Die Vorschau wird nur aktualisiert, wenn sich Formulardaten geändert haben. Auf 0 setzen zum Deaktivieren. Standard: 5.'; \ 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..49f0a1c 100644 --- a/core/components/magicpreview/lexicon/en/default.inc.php +++ b/core/components/magicpreview/lexicon/en/default.inc.php @@ -2,6 +2,10 @@ $_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.reload_preview'] = 'Reload preview'; +$_lang['magicpreview.preview_button'] = 'Preview'; +$_lang['magicpreview.idle_message'] = 'Click <strong>Preview</strong> to generate a preview.'; $_lang['magicpreview.bp_full'] = 'Full'; $_lang['magicpreview.bp_desktop'] = 'Desktop'; @@ -19,4 +23,12 @@ $_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. "New Window" 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). "On Page" shrinks the editor to make room for a permanent column.'; +$_lang['setting_magicpreview.panel_extended'] = 'Panel Extended'; +$_lang['setting_magicpreview.panel_extended_desc'] = 'Start with the preview panel extended when the resource form loads. Automatically generates an initial preview. Only applies when preview mode is set to "Panel". Default: No.'; +$_lang['setting_magicpreview.auto_refresh_interval'] = 'Auto Refresh Interval'; +$_lang['setting_magicpreview.auto_refresh_interval_desc'] = 'Seconds between automatic preview refreshes while the panel is open. The preview only refreshes when form data has changed. Set to 0 to disable. Default: 5.'; diff --git a/core/components/magicpreview/processors/resource/PreviewTrait.php b/core/components/magicpreview/processors/resource/PreviewTrait.php index 239e7be..36c1f3e 100644 --- a/core/components/magicpreview/processors/resource/PreviewTrait.php +++ b/core/components/magicpreview/processors/resource/PreviewTrait.php @@ -32,7 +32,10 @@ public function fireBeforeSaveEvent() } $data = $this->object->toArray('', true); - $key = bin2hex(random_bytes(12)); + // Use a deterministic hash of the data so identical content + // returns the same key. This allows the client-side auto-refresh + // to skip reloading the iframe when nothing has actually changed. + $key = substr(hash('sha256', json_encode($data)), 0, 24); $this->modx->cacheManager->set($this->object->get('id') . '/' . $key, $data, 3600, [ xPDO::OPT_CACHE_KEY => 'magicpreview' ]); diff --git a/core/components/magicpreview/templates/preview.tpl b/core/components/magicpreview/templates/preview.tpl index 4420abe..dc2f8bd 100644 --- a/core/components/magicpreview/templates/preview.tpl +++ b/core/components/magicpreview/templates/preview.tpl @@ -84,7 +84,7 @@ // Handle dynamic breakpoint sizing var breakpoints = document.querySelectorAll('.mmmp-js-breakpoint-input'); - breakpoints.forEach(function(bp) { + breakpoints.forEach(function (bp) { bp.addEventListener('change', function () { switch (this.value) { case 'full':