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 = ''
+ + '
'
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + '
'
+ + '
'
+ + '
' + 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('
');
}
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å Forhåndsvisning 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 Vorschau, 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 Preview 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':