From ff9360b7472dac274156b3e22fe5ea17d262da28 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 12 Jun 2026 23:12:59 +0800 Subject: [PATCH 1/3] Move draft banner to fixed viewport position and adjust ExtJS layout The banner is now appended to document.body as a fixed element (top: 0), sitting entirely outside the ExtJS layout. CSS :has() selectors push the MODX layout panels below it; relayoutModx() overrides getViewSize() on the ExtJS Viewport to subtract the banner height so inner panel bodies are not clipped. The race condition in the setTimeout cleanup path is also fixed: recompute fresh DOM state inside the callback and add the missing else-if branch to restore getViewSize when both panel and banner are gone. --- assets/components/magicpreview/css/mgr.css | 47 +++++++++++++++++- assets/components/magicpreview/js/panel.js | 52 +++++++++++++------- assets/components/magicpreview/js/preview.js | 19 +++---- 3 files changed, 87 insertions(+), 31 deletions(-) diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index 02fff6a..2f50e53 100644 --- a/assets/components/magicpreview/css/mgr.css +++ b/assets/components/magicpreview/css/mgr.css @@ -3,11 +3,17 @@ ========================================================================== */ /* -------------------------------------------------------------------------- - Draft banner: notification bar above the resource panel. - Appended to #modx-panel-resource-div via plain DOM. + Draft banner: fixed notification bar at the top of the viewport. + Appended to document.body so it sits outside the ExtJS layout and + does not affect resource-panel height calculations. -------------------------------------------------------------------------- */ .mmmp-draft-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 200; display: flex; align-items: center; gap: 0.75rem; @@ -84,6 +90,43 @@ background: #E4E4E4; } +/* In MODX 2 the manager header is ~55px tall; place the banner below it */ +body.magicpreview_modx2 .mmmp-draft-banner { + top: 55px; +} + +/* Push the ExtJS layout panels below the banner. + CSS handles the top-position shift; relayoutModx() overrides getViewSize() + to return a reduced height so the border layout recalculates every inner + panel-body height — ensuring nothing is clipped at the bottom. + MODX 3 desktop: #modx-header = west icon bar; #modx-split-wrapper = center + (the nested border layout that contains the tree + #modx-content). */ +@media (min-width: 641px) { + body.magicpreview_modx3:has(.mmmp-draft-banner) #modx-header, + body.magicpreview_modx3:has(.mmmp-draft-banner) #modx-split-wrapper, + body.magicpreview_modx3:has(.mmmp-draft-banner) #modx-content { + top: 45px !important; + height: calc(100vh - 45px) !important; + } +} + +/* MODX 2: north region stays at 0–55px; banner sits at top: 55px. + Shift the west and center panels below the banner (55 + 45 = 100px). */ +body.magicpreview_modx2:has(.mmmp-draft-banner) #modx-leftbar, +body.magicpreview_modx2:has(.mmmp-draft-banner) #modx-content { + top: 100px !important; + height: calc(100vh - 100px) !important; +} + +/* Push the preview panel below the banner */ +body:has(.mmmp-draft-banner) .mmmp-panel { + top: 45px; +} + +body.magicpreview_modx2:has(.mmmp-draft-banner) .mmmp-panel { + top: 100px; +} + /* Push the fixed-position action buttons below the banner */ body:has(.mmmp-draft-banner) #modx-action-buttons { top: 48px; diff --git a/assets/components/magicpreview/js/panel.js b/assets/components/magicpreview/js/panel.js index c7b7a0f..ab75d02 100644 --- a/assets/components/magicpreview/js/panel.js +++ b/assets/components/magicpreview/js/panel.js @@ -514,15 +514,15 @@ 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. + * Overrides the ExtJS Viewport's getViewSize() so the border layout + * recalculates within the available space, then calls doLayout() to + * cascade setSize()/onResize() through every child panel. * - * 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. + * Width is reduced when the on-page preview panel is open (to leave + * room for it on the right). Height is reduced when the draft banner + * is visible (so the scroll container inside #modx-content is sized + * correctly and nothing is clipped behind the banner). Both reductions + * are applied together when both are active. */ function relayoutModx() { var layout = Ext.getCmp('modx-layout'); @@ -531,18 +531,20 @@ } var panelIsOpen = document.body.classList.contains('mmmp-panel-onpage-active'); + var bannerEl = document.getElementById('mmmp-draft-banner'); + var bannerH = bannerEl ? bannerEl.offsetHeight : 0; + var needsOverride = panelIsOpen || bannerH > 0; + var pw = panelIsOpen ? getPanelWidth() : 0; - if (panelIsOpen) { + if (needsOverride) { // 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 + height: window.innerHeight - bannerH }; }; } else if (_originalGetViewSize) { @@ -553,15 +555,22 @@ // 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(); + // Re-read both values now that the DOM has settled + var bannerElInner = document.getElementById('mmmp-draft-banner'); + var bannerHInner = bannerElInner ? bannerElInner.offsetHeight : 0; + var panelIsOpenInner = document.body.classList.contains('mmmp-panel-onpage-active'); + var pwInner = panelIsOpenInner ? getPanelWidth() : 0; + var needsOverrideInner = panelIsOpenInner || bannerHInner > 0; + if (needsOverrideInner) { layout.el.getViewSize = function() { return { - width: window.innerWidth - pw, - height: window.innerHeight + width: window.innerWidth - pwInner, + height: window.innerHeight - bannerHInner }; }; + } else if (_originalGetViewSize) { + delete layout.el.getViewSize; + _originalGetViewSize = null; } layout.doLayout(); }, 50); @@ -651,6 +660,13 @@ * Set the last preview hash. * @param {string|null} hash */ - setLastHash: function(hash) { lastHash = hash; } + setLastHash: function(hash) { lastHash = hash; }, + + /** + * Re-run the ExtJS Viewport layout override. Called by preview.js + * when the draft banner is shown or hidden so the height reduction + * is applied consistently regardless of panel state. + */ + relayout: relayoutModx }; })(); diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index b034765..4934060 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -593,6 +593,7 @@ var finish = function() { if (banner) { banner.remove(); + MagicPreview._panel.relayout(); } MODx.msg.status({ title: lexicon('draft_discarded'), @@ -761,11 +762,11 @@ } /** - * Shows a draft banner above the resource panel. Appended to the - * #modx-panel-resource-div container which sits directly above the - * ExtJS-rendered resource panel in the DOM. Offers View, Share, - * Restore and Discard for the saved draft; stays visible until the - * draft is restored or discarded. + * Shows a draft banner fixed at the top of the viewport. Appended to + * document.body so it sits entirely outside the ExtJS layout and does + * not affect the resource panel's height calculations. Offers View, + * Share, Restore and Discard for the saved draft; stays visible until + * the draft is restored or discarded. */ function showDraftBanner() { var c = config(); @@ -773,11 +774,6 @@ return; } - var container = document.getElementById('modx-panel-resource-div'); - if (!container) { - return; - } - var banner = document.createElement('div'); banner.id = 'mmmp-draft-banner'; banner.className = 'mmmp-draft-banner'; @@ -793,7 +789,8 @@ + lexicon('draft_discard') + '' + ''; - container.appendChild(banner); + document.body.appendChild(banner); + MagicPreview._panel.relayout(); // Delegate click events from the banner's buttons banner.addEventListener('click', function(e) { From db31071237678d12f69e84d8b491a17b95be7b1b Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 12 Jun 2026 23:16:32 +0800 Subject: [PATCH 2/3] Add click-to-field support via postMessage Clicking a data-magicpreview-field element in the preview iframe sends a postMessage to the manager. The manager scrolls the resource form to the matching ContentBlocks field (data-field attribute), activates its tab if needed, focuses the element, and briefly highlights it with a blue outline that fades via CSS transition. Window mode is supported via a relay listener in preview.tpl that forwards messages from the frontend iframe to the manager via window.opener. --- assets/components/magicpreview/css/mgr.css | 11 ++ assets/components/magicpreview/js/preview.js | 127 ++++++++++++++++++ .../elements/plugins/magicpreview.plugin.php | 16 +++ .../magicpreview/templates/preview.tpl | 18 +++ 4 files changed, 172 insertions(+) diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index 2f50e53..3498506 100644 --- a/assets/components/magicpreview/css/mgr.css +++ b/assets/components/magicpreview/css/mgr.css @@ -562,3 +562,14 @@ a.mmmp-share-revoke:hover, a.mmmp-share-revoke:focus { color: #843534; } + +/* -------------------------------------------------------------------------- + Click-to-field: brief highlight on the manager field scrolled to from the + preview. Transition fades the outline out after the initial flash. + -------------------------------------------------------------------------- */ + +.mmmp-field-highlight { + outline: 2px solid hsl(207, 70%, 53%); + outline-offset: 3px; + transition: outline-color 1.2s ease-out; +} diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index 4934060..35d14da 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -905,6 +905,105 @@ }); } + // ========================================================================= + // Click-to-field: scroll the manager form to the ContentBlocks field + // matching a postMessage from the preview iframe. ContentBlocks is not + // assumed to be installed — the function is a no-op when no matching + // element exists, falling back to scrolling to page top. + // ========================================================================= + + /** + * Scroll the manager resource form to the ContentBlocks field identified + * by field/index, activating the correct tab first if needed. + * + * @param {string} field - Numeric ContentBlocks field id. + * @param {number} [index=0] - 0-based index when the same field type + * appears more than once on the page. + */ + function scrollToField(field, index) { + try { + var idx = typeof index === 'number' ? index : 0; + var el = null; + + // data-field attribute — ContentBlocks manager
  • + var byData = document.querySelectorAll('[data-field="' + CSS.escape(field) + '"]'); + if (byData.length > idx) { el = byData[idx]; } + + // Nothing matched — scroll to top so the user can orient themselves + if (!el) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + + // Activate the tab containing the element (Document, TV, Settings…) if it + // isn't already active. ExtJS keeps inactive tab content in the DOM (hidden + // via CSS), so dom.contains() finds elements regardless of active tab. + var needsTabSwitch = false; + try { + var tabPanel = Ext.getCmp('modx-resource-tabs'); + if (tabPanel && tabPanel.items && tabPanel.getActiveTab) { + var activeTab = tabPanel.getActiveTab(); + tabPanel.items.each(function(tab) { + try { + var tabEl = tab.getEl && tab.getEl(); + if (tabEl && tabEl.dom && tabEl.dom.contains(el) && activeTab !== tab) { + tabPanel.setActiveTab(tab); + needsTabSwitch = true; + } + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + }); + } + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + + var doScroll = function() { + try { + // Scroll within the ExtJS center panel body + var scrollContainer = document.querySelector('#modx-content > .x-panel-body'); + if (!scrollContainer) { + // Fallback: walk up the DOM and use the first real scroll container + var p = el.parentElement; + while (p && p !== document.documentElement) { + var st = window.getComputedStyle(p); + var ov = st.overflowY; + if ((ov === 'auto' || ov === 'scroll') && p.scrollHeight > p.clientHeight) { + scrollContainer = p; + break; + } + p = p.parentElement; + } + } + if (scrollContainer) { + var cRect = scrollContainer.getBoundingClientRect(); + var eRect = el.getBoundingClientRect(); + var desired = scrollContainer.scrollTop + (eRect.top - cRect.top) - (cRect.height - eRect.height) / 2; + scrollContainer.scrollTo({ top: Math.max(0, desired), behavior: 'smooth' }); + } + if (!el.getAttribute('tabindex') && !/^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(el.tagName)) { + el.setAttribute('tabindex', '-1'); + } + el.focus({ preventScroll: true }); + el.classList.add('mmmp-field-highlight'); + setTimeout(function() { el.classList.remove('mmmp-field-highlight'); }, 1200); + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + }; + + // Brief delay after a tab switch so the ExtJS transition completes + if (needsTabSwitch) { + setTimeout(doScroll, 150); + } else { + doScroll(); + } + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + } + // ========================================================================= // ExtJS: Button injection // ========================================================================= @@ -1142,5 +1241,33 @@ return; } }, true); + + // ===================================================================== + // Click-to-field: postMessage listener + // Accepts messages from the preview iframe (panel mode) or from the + // preview popup relayed via preview.tpl (window mode). + // ===================================================================== + + // Pre-compute once — origin never changes during the page session. + var previewOrigin = ''; + try { + previewOrigin = new URL(config().baseFrameUrl).origin; + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + + window.addEventListener('message', function(e) { + var data = e.data; + if (!data || typeof data !== 'object' || data.type !== 'magicpreview:scrollToField') { + return; + } + // Accept from the frontend's origin (panel mode) or manager's own + // origin (preview.tpl relay for window mode). + if (e.origin !== previewOrigin && e.origin !== window.location.origin) { + return; + } + if (typeof data.field !== 'string' || !data.field) { return; } + scrollToField(data.field, data.index); + }, false); }); })(); diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index c1a42ae..e0db5b6 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -264,6 +264,22 @@ // how an in-memory resource is primed for an overridden render. $service->applyPreviewData($modx->resource, $data); } + + // Inject click-to-field support: delegated click listener that sends a + // postMessage to the manager when a data-magicpreview-field element is clicked. + // ContentBlocks is not required — this is a no-op when no attributes exist. + $modx->regClientStartupHTMLBlock(''); + break; } diff --git a/core/components/magicpreview/templates/preview.tpl b/core/components/magicpreview/templates/preview.tpl index dc2f8bd..5deab84 100644 --- a/core/components/magicpreview/templates/preview.tpl +++ b/core/components/magicpreview/templates/preview.tpl @@ -111,6 +111,24 @@ e.preventDefault(); window.close(); }); + + // Window mode relay: the frontend iframe sends postMessage to window.top, + // which is this preview window. Forward it to the manager via window.opener. + // Only relay from the expected frontend origin (the iframe's src origin). + var relayOrigin = ''; + try { relayOrigin = new URL(document.getElementById('mmmp-js-frame-inner').src).origin; } catch (ex) {} + window.addEventListener('message', function(e) { + var data = e.data; + if (!data || typeof data !== 'object' || data.type !== 'magicpreview:scrollToField') { + return; + } + if (!relayOrigin || e.origin !== relayOrigin) { + return; + } + if (window.opener && !window.opener.closed) { + window.opener.postMessage(data, window.location.origin); + } + }, false); })() {else} From 73c7fc16f7c4da6371aa42e54115ea69c2e23b46 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Sun, 14 Jun 2026 13:57:33 +0800 Subject: [PATCH 3/3] Add ContentBlocks_AfterFieldRender handler for click-to-field markers --- _bootstrap/index.php | 7 ++++++ _build/events/events.magicpreview.php | 1 + .../elements/plugins/magicpreview.plugin.php | 23 +++++++++++++++++++ .../model/magicpreview/magicpreview.class.php | 7 ++++++ .../processors/resource/PreviewTrait.php | 19 ++++++++++----- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/_bootstrap/index.php b/_bootstrap/index.php index 1c910bb..c19ab7c 100644 --- a/_bootstrap/index.php +++ b/_bootstrap/index.php @@ -125,6 +125,13 @@ ], ['pluginid','event'], false)) { echo "Error creating modPluginEvent.\n"; } + if (!createObject('modPluginEvent', [ + 'pluginid' => $vcPlugin->get('id'), + 'event' => 'ContentBlocks_AfterFieldRender', + 'priority' => 0, + ], ['pluginid','event'], false)) { + echo "Error creating modPluginEvent.\n"; + } } diff --git a/_build/events/events.magicpreview.php b/_build/events/events.magicpreview.php index dd1f617..785ed4f 100644 --- a/_build/events/events.magicpreview.php +++ b/_build/events/events.magicpreview.php @@ -7,6 +7,7 @@ 'OnDocFormSave', 'OnLoadWebDocument', 'OnManagerPageBeforeRender', + 'ContentBlocks_AfterFieldRender', ]; foreach ($e as $ev) { diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index e0db5b6..a9e5b5d 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -282,6 +282,29 @@ break; + /** + * Fired by ContentBlocks (1.16+) once per field instance with its complete + * rendered output. Here we wrap the field in a div with inline display:contents + * in an attempt to not interfere with the page layout in the preview. + * Wrapping is the only reliable way to target each field's output and remain valid HTML. + * + * @var mixed $html + * @var object $field cbField + * @var array $fieldData + */ + case 'ContentBlocks_AfterFieldRender': + if (!$service->addFieldMarkers || !is_scalar($html) || !is_object($field) || !method_exists($field, 'get')) { + break; + } + $modx->event->output( + '
    ' + . $html + . '
    ' + ); + break; + } return true; \ No newline at end of file diff --git a/core/components/magicpreview/model/magicpreview/magicpreview.class.php b/core/components/magicpreview/model/magicpreview/magicpreview.class.php index e85c642..939844e 100644 --- a/core/components/magicpreview/model/magicpreview/magicpreview.class.php +++ b/core/components/magicpreview/model/magicpreview/magicpreview.class.php @@ -14,6 +14,13 @@ class MagicPreview public ?modX $modx = null; public array $config = []; public bool $debug = false; + /** + * @var bool True only while the preview processor fires OnResourceMagicPreview + * (see PreviewTrait::fireBeforeSaveEvent). Checked by the plugin's + * ContentBlocks_AfterFieldRender handler so jump-to-field markers are added + * during preview renders only, never on normal saves or content rebuilds. + */ + public bool $addFieldMarkers = false; const VERSION = '1.6.0-pl'; diff --git a/core/components/magicpreview/processors/resource/PreviewTrait.php b/core/components/magicpreview/processors/resource/PreviewTrait.php index 660ce69..70a05cc 100644 --- a/core/components/magicpreview/processors/resource/PreviewTrait.php +++ b/core/components/magicpreview/processors/resource/PreviewTrait.php @@ -16,11 +16,20 @@ trait PreviewTrait public function fireBeforeSaveEvent() { + $service = $this->getMagicPreviewService(); + // Invoke an event to allow other modules to prepare/modify the resource before preview. - $this->modx->invokeEvent('OnResourceMagicPreview', [ - 'resource' => $this->object, - 'properties' => $this->getProperties(), - ]); + // The flag marks this render as a preview so listeners that fire during it (the + // plugin's ContentBlocks_AfterFieldRender handler) add jump-to-field markers. + $service->addFieldMarkers = true; + try { + $this->modx->invokeEvent('OnResourceMagicPreview', [ + 'resource' => $this->object, + 'properties' => $this->getProperties(), + ]); + } finally { + $service->addFieldMarkers = false; + } $this->failedSuccessfully = true; @@ -38,8 +47,6 @@ public function fireBeforeSaveEvent() } $data = $this->object->toArray('', true); - $service = $this->getMagicPreviewService(); - // Cache the preview data under a deterministic content hash for the // ?show_preview= front-end render (see MagicPreview::cachePreviewData). $this->previewHash = $service->cachePreviewData((int) $this->object->get('id'), $data);