From 0f6b11714949f52ab70b4d9dcc7ebfe0e6d7fbc2 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 12 Jun 2026 23:12:59 +0800 Subject: [PATCH 1/7] 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 1b2d44e9ce71efa50dc593d2a392eb8852932c77 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 12 Jun 2026 23:16:32 +0800 Subject: [PATCH 2/7] 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 6f844dadb704b0dd9f3f22d10346934948d55c16 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Sun, 14 Jun 2026 13:57:33 +0800 Subject: [PATCH 3/7] 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 c4e43a0..2797819 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.7.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); From fb174d1d27cb5bced718908cc792fb1b8721be41 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 26 Jun 2026 18:00:29 +0800 Subject: [PATCH 4/7] Refactor to use ContentBlock's existing ContentBlocks_AfterParse event. --- _bootstrap/index.php | 2 +- _build/events/events.magicpreview.php | 2 +- assets/components/magicpreview/js/preview.js | 8 ++- .../elements/plugins/magicpreview.plugin.php | 62 +++++++++++-------- .../MagicPreviewContentBlocksParser.php | 55 ++++++++++++++++ .../model/magicpreview/magicpreview.class.php | 2 +- .../processors/resource/PreviewTrait.php | 18 +++++- 7 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php diff --git a/_bootstrap/index.php b/_bootstrap/index.php index c19ab7c..eb8a737 100644 --- a/_bootstrap/index.php +++ b/_bootstrap/index.php @@ -127,7 +127,7 @@ } if (!createObject('modPluginEvent', [ 'pluginid' => $vcPlugin->get('id'), - 'event' => 'ContentBlocks_AfterFieldRender', + 'event' => 'ContentBlocks_AfterParse', '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 785ed4f..d360cf6 100644 --- a/_build/events/events.magicpreview.php +++ b/_build/events/events.magicpreview.php @@ -7,7 +7,7 @@ 'OnDocFormSave', 'OnLoadWebDocument', 'OnManagerPageBeforeRender', - 'ContentBlocks_AfterFieldRender', + 'ContentBlocks_AfterParse', ]; foreach ($e as $ev) { diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index 35d14da..dd8d355 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -927,7 +927,9 @@ // data-field attribute — ContentBlocks manager
  • var byData = document.querySelectorAll('[data-field="' + CSS.escape(field) + '"]'); - if (byData.length > idx) { el = byData[idx]; } + if (byData.length > idx) { + el = byData[idx]; + } // Nothing matched — scroll to top so the user can orient themselves if (!el) { @@ -1266,7 +1268,9 @@ if (e.origin !== previewOrigin && e.origin !== window.location.origin) { return; } - if (typeof data.field !== 'string' || !data.field) { 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 a9e5b5d..e4726f4 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -1,4 +1,5 @@ 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; - /** - * 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')) { + case 'ContentBlocks_AfterParse': + /** + * @var string $tpl Rendered field output (by reference — set via $modx->event->output()) + * @var array $phs Field data: 'field' (id), 'field_type_idx' (0-based instance count), 'value', etc. + */ + if (!$service->addFieldMarkers) { + break; + } + // $phs is the field data array passed by ContentBlocks to parse(). + // MagicPreviewContentBlocksParser (installed in PreviewTrait) prevents MODX's default + // parseProperties() from collapsing array params with a 'value' key to a + // string, so $phs arrives here as the full associative array. + if (!is_array($phs) || !isset($phs['field_type_idx']) || !array_key_exists('field', $phs)) { + break; + } + // List input calls parse() once per item and once for the wrapper. Each item + // carries 'value' (item text) merged from $settings alongside 'items' + // (the full item list), so 'value' + 'items' together identify a sub-item + // call — skip those; wrap only the field-level (wrapper) parse. + if (array_key_exists('value', $phs) && array_key_exists('items', $phs)) { break; } $modx->event->output( '
    ' - . $html + . ' data-magicpreview-field="' . (int)$phs['field'] . '"' + . ' data-magicpreview-idx="' . (int)$phs['field_type_idx'] . '">' + . $tpl . '
    ' ); break; diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php new file mode 100644 index 0000000..d4f68c9 --- /dev/null +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php @@ -0,0 +1,55 @@ +parser inside PreviewTrait::fireBeforeSaveEvent() + * so that event params received by the ContentBlocks_AfterParse plugin handler + * remain as their original arrays rather than being collapsed to strings. + * ContentBlocks' loadParser()/restoreParser() correctly preserves this instance + * through its own cbParser swap cycle. + */ + +// MODX 3 branch — class defined only when \MODX\Revolution\modParser is available. +if (class_exists('\MODX\Revolution\modParser', false)) { + if (!class_exists('MagicPreviewContentBlocksParser', false)) { + class MagicPreviewContentBlocksParser extends \MODX\Revolution\modParser + { + /** @param mixed $propSource */ + public function parseProperties($propSource) + { + if (is_array($propSource)) { + $properties = []; + foreach ($propSource as $propName => &$property) { + $properties[$propName] = &$property; + } + return $properties; + } + return parent::parseProperties($propSource); + } + } + } +} elseif (!class_exists('MagicPreviewContentBlocksParser', false)) { + // MODX 2 branch. + class MagicPreviewContentBlocksParser extends modParser + { + /** @param mixed $propSource */ + public function parseProperties($propSource) + { + if (is_array($propSource)) { + $properties = []; + foreach ($propSource as $propName => &$property) { + $properties[$propName] = &$property; + } + return $properties; + } + return parent::parseProperties($propSource); + } + } +} diff --git a/core/components/magicpreview/model/magicpreview/magicpreview.class.php b/core/components/magicpreview/model/magicpreview/magicpreview.class.php index 2797819..e8717cc 100644 --- a/core/components/magicpreview/model/magicpreview/magicpreview.class.php +++ b/core/components/magicpreview/model/magicpreview/magicpreview.class.php @@ -17,7 +17,7 @@ class MagicPreview /** * @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 + * ContentBlocks_AfterParse handler so jump-to-field markers are added * during preview renders only, never on normal saves or content rebuilds. */ public bool $addFieldMarkers = false; diff --git a/core/components/magicpreview/processors/resource/PreviewTrait.php b/core/components/magicpreview/processors/resource/PreviewTrait.php index 70a05cc..cdf9a1b 100644 --- a/core/components/magicpreview/processors/resource/PreviewTrait.php +++ b/core/components/magicpreview/processors/resource/PreviewTrait.php @@ -20,7 +20,21 @@ public function fireBeforeSaveEvent() // Invoke an event to allow other modules to prepare/modify the resource before preview. // 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. + // plugin's ContentBlocks_AfterParse handler) add jump-to-field markers. + // Clear the element cache so every ContentBlocks_AfterParse plugin execution + // runs fresh rather than returning a cached (empty) event output. + // Install a safe parser that bypasses parseProperties() collapsing arrays-with-'value' + // to strings — ContentBlocks_AfterParse passes $phs as a plain associative array + // that may have a 'value' key, which the default parser would otherwise mangle. + // ContentBlocks' loadParser()/restoreParser() correctly preserves this instance. + $this->modx->getParser(); + if (!class_exists('MagicPreviewContentBlocksParser', false)) { + require_once __DIR__ . '/../../model/magicpreview/MagicPreviewContentBlocksParser.php'; + } + $savedParser = $this->modx->parser; + $this->modx->parser = new MagicPreviewContentBlocksParser($this->modx); + $savedElementCache = $this->modx->elementCache; + $this->modx->elementCache = []; $service->addFieldMarkers = true; try { $this->modx->invokeEvent('OnResourceMagicPreview', [ @@ -29,6 +43,8 @@ public function fireBeforeSaveEvent() ]); } finally { $service->addFieldMarkers = false; + $this->modx->elementCache = $savedElementCache; + $this->modx->parser = $savedParser; } $this->failedSuccessfully = true; From 78b31eff4ad29f2cf3367ff97e0b6726487469c8 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 26 Jun 2026 18:24:54 +0800 Subject: [PATCH 5/7] Also use a separate parser replacement for core resource fields in preview window --- _bootstrap/index.php | 7 + _build/events/events.magicpreview.php | 1 + assets/components/magicpreview/js/preview.js | 20 +++ .../elements/plugins/magicpreview.plugin.php | 57 ++++++++ .../magicpreview/MagicPreviewCoreParser.php | 128 ++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php diff --git a/_bootstrap/index.php b/_bootstrap/index.php index eb8a737..285efbe 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' => 'OnWebPagePrerender', + 'priority' => 0, + ], ['pluginid','event'], false)) { + echo "Error creating modPluginEvent.\n"; + } if (!createObject('modPluginEvent', [ 'pluginid' => $vcPlugin->get('id'), 'event' => 'ContentBlocks_AfterParse', diff --git a/_build/events/events.magicpreview.php b/_build/events/events.magicpreview.php index d360cf6..2d87a8e 100644 --- a/_build/events/events.magicpreview.php +++ b/_build/events/events.magicpreview.php @@ -7,6 +7,7 @@ 'OnDocFormSave', 'OnLoadWebDocument', 'OnManagerPageBeforeRender', + 'OnWebPagePrerender', 'ContentBlocks_AfterParse', ]; diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index dd8d355..8f5f540 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -931,6 +931,26 @@ el = byData[idx]; } + // ExtJS component — MODX core resource fields (pagetitle, longtitle, etc.) + if (!el) { + try { + var cmp = Ext.getCmp('modx-resource-' + field); + if (cmp && cmp.getEl && cmp.getEl()) { + el = cmp.getEl().dom; + } + } catch (ex) { + console.error('[MagicPreview] scrollToField error:', ex); + } + } + + // name attribute — plain inputs and TV fields + if (!el) { + var byName = document.querySelectorAll('[name="' + CSS.escape(field) + '"]'); + if (byName.length > idx) { + el = byName[idx]; + } + } + // Nothing matched — scroll to top so the user can orient themselves if (!el) { window.scrollTo({ top: 0, behavior: 'smooth' }); diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index e4726f4..c684618 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -266,6 +266,16 @@ $service->applyPreviewData($modx->resource, $data); } + // Install the core-fields parser so [[*pagetitle]], [[*description]], etc. + // are wrapped with control-character markers during template rendering. + // OnWebPagePrerender resolves those markers into data-magicpreview-field spans. + // No restoration is needed — the request ends after the page is rendered. + $modx->getParser(); + if (!class_exists('MagicPreviewCoreParser', false)) { + require_once $service->config['modelPath'] . 'magicpreview/MagicPreviewCoreParser.php'; + } + $modx->parser = new MagicPreviewCoreParser($modx); + // Inject click-to-field support: hover outline + delegated click listener // that sends a postMessage to the manager when a data-magicpreview-field // element is clicked. ContentBlocks is not required — no-op when no attributes @@ -317,6 +327,53 @@ ); break; + case 'OnWebPagePrerender': + if (!array_key_exists('show_preview', $_GET)) { + break; + } + // Resolve the STX/ETX markers injected by MagicPreviewCoreParser into + // click-to-field spans. Markers are stripped from unsafe HTML contexts + // (inside , inside HTML attribute markup) and converted to spans + // everywhere else so they don't produce invalid HTML. + $output = &$modx->resource->_output; + if (strpos($output, "\x02") === false) { + break; + } + + // Strip markers from inside ... (covers , <meta>, <link>). + $output = preg_replace_callback( + '/(<head[^>]*>)(.*?)(<\/head>)/si', + function ($m) { + return $m[1] + . preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[2]) + . $m[3]; + }, + $output + ); + + // Strip markers from inside HTML opening tags (attribute values such as + // <img alt="[[*description]]"> or <meta content="[[*pagetitle]]">). + $output = preg_replace_callback( + '/<[a-zA-Z][^>]*>/s', + function ($m) { + return preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[0]); + }, + $output + ); + + // Convert remaining markers (body text contexts) to click-to-field spans. + $output = preg_replace_callback( + "/\x02MMMP:([^\x02]*)\x02(.*?)\x03MMMP\x03/s", + function ($m) { + return '<span data-magicpreview-field="' . htmlspecialchars($m[1], ENT_QUOTES) . '"' + . ' style="display:contents">' + . $m[2] + . '</span>'; + }, + $output + ); + break; + } return true; \ No newline at end of file diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php new file mode 100644 index 0000000..d67efa7 --- /dev/null +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php @@ -0,0 +1,128 @@ +<?php + +/** + * Frontend parser substitution used during preview page rendering. + * + * Overrides processTag() to intercept [[*fieldname]] resource field tags for a + * known set of wrappable core fields. Instead of returning plain text, it wraps + * the rendered value in STX/ETX control-character markers: + * + * \x02MMMP:pagetitle\x02rendered value\x03MMMP\x03 + * + * These markers are safe to embed anywhere in HTML because control characters + * (U+0002 / U+0003) never appear in valid HTML content. The OnWebPagePrerender + * plugin handler then resolves them: markers inside <head> or HTML attribute + * contexts are stripped (leaving just the value); markers in body content become + * <span data-magicpreview-field="pagetitle" style="display:contents"> elements + * that the frontend click handler can target. + * + * Installed on $modx->parser during OnLoadWebDocument (preview requests only). + * Restoration is not needed — the request ends after the page is rendered. + */ + +// MODX 3 branch — class defined only when \MODX\Revolution\modParser is available. +if (class_exists('\MODX\Revolution\modParser', false)) { + if (!class_exists('MagicPreviewCoreParser', false)) { + class MagicPreviewCoreParser extends \MODX\Revolution\modParser + { + /** @var string[] Core resource fields that are safe to wrap with click-to-field markers. */ + protected $wrappableFields = [ + 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', + ]; + + /** + * @param array|string $tag + * @param bool $processUncacheable + */ + public function processTag($tag, $processUncacheable = true) + { + $innerTag = is_array($tag) ? (isset($tag[1]) ? (string)$tag[1] : '') : (string)$tag; + + // Detect the tag token, accounting for the optional uncacheable ! prefix. + $tagName = trim($innerTag); + $tokenOffset = 0; + if (substr($tagName, 0, 1) === '!') { + $tokenOffset = 1; + } + $token = substr($tagName, $tokenOffset, 1); + + if ($token !== '*') { + return parent::processTag($tag, $processUncacheable); + } + + // Extract the bare field name: strip token, optional # modifier, + // property string (after ?), and output modifiers (after :). + $fieldName = substr($tagName, $tokenOffset + 1); + if (substr($fieldName, 0, 1) === '#') { + $fieldName = substr($fieldName, 1); + } + $fieldName = explode('?', $fieldName)[0]; + $fieldName = explode(':', $fieldName)[0]; + $fieldName = trim($fieldName); + + if (!in_array($fieldName, $this->wrappableFields, true)) { + return parent::processTag($tag, $processUncacheable); + } + + $output = parent::processTag($tag, $processUncacheable); + + // Only wrap if the parent returned a processed string value, not the + // original tag back (which starts with [[ when unresolved). + if (!is_string($output) || $output === '' || substr($output, 0, 2) === '[[') { + return $output; + } + + return "\x02MMMP:{$fieldName}\x02{$output}\x03MMMP\x03"; + } + } + } +} elseif (!class_exists('MagicPreviewCoreParser', false)) { + // MODX 2 branch. + class MagicPreviewCoreParser extends modParser + { + /** @var string[] Core resource fields that are safe to wrap with click-to-field markers. */ + protected $wrappableFields = [ + 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', + ]; + + /** + * @param array|string $tag + * @param bool $processUncacheable + */ + public function processTag($tag, $processUncacheable = true) + { + $innerTag = is_array($tag) ? (isset($tag[1]) ? (string)$tag[1] : '') : (string)$tag; + + $tagName = trim($innerTag); + $tokenOffset = 0; + if (substr($tagName, 0, 1) === '!') { + $tokenOffset = 1; + } + $token = substr($tagName, $tokenOffset, 1); + + if ($token !== '*') { + return parent::processTag($tag, $processUncacheable); + } + + $fieldName = substr($tagName, $tokenOffset + 1); + if (substr($fieldName, 0, 1) === '#') { + $fieldName = substr($fieldName, 1); + } + $fieldName = explode('?', $fieldName)[0]; + $fieldName = explode(':', $fieldName)[0]; + $fieldName = trim($fieldName); + + if (!in_array($fieldName, $this->wrappableFields, true)) { + return parent::processTag($tag, $processUncacheable); + } + + $output = parent::processTag($tag, $processUncacheable); + + if (!is_string($output) || $output === '' || substr($output, 0, 2) === '[[') { + return $output; + } + + return "\x02MMMP:{$fieldName}\x02{$output}\x03MMMP\x03"; + } + } +} From 18476c0ed1ff3c67e697b32f12579f12c9b061d9 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Fri, 26 Jun 2026 18:34:00 +0800 Subject: [PATCH 6/7] Include TVs in the MagicPreviewCoreParser class --- .../magicpreview/MagicPreviewCoreParser.php | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php index d67efa7..4dadedd 100644 --- a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php @@ -3,11 +3,12 @@ /** * Frontend parser substitution used during preview page rendering. * - * Overrides processTag() to intercept [[*fieldname]] resource field tags for a - * known set of wrappable core fields. Instead of returning plain text, it wraps + * Overrides processTag() to intercept [[*fieldname]] resource field tags for + * known wrappable core fields and TVs. Instead of returning plain text, it wraps * the rendered value in STX/ETX control-character markers: * * \x02MMMP:pagetitle\x02rendered value\x03MMMP\x03 + * \x02MMMP:tv42\x02rendered value\x03MMMP\x03 * * These markers are safe to embed anywhere in HTML because control characters * (U+0002 / U+0003) never appear in valid HTML content. The OnWebPagePrerender @@ -16,6 +17,9 @@ * <span data-magicpreview-field="pagetitle" style="display:contents"> elements * that the frontend click handler can target. * + * TV tags use "tv{id}" as the field identifier (e.g. "tv42") so the manager-side + * scrollToField() can locate the input via [name="tv42"]. + * * Installed on $modx->parser during OnLoadWebDocument (preview requests only). * Restoration is not needed — the request ends after the page is rendered. */ @@ -25,11 +29,14 @@ if (!class_exists('MagicPreviewCoreParser', false)) { class MagicPreviewCoreParser extends \MODX\Revolution\modParser { - /** @var string[] Core resource fields that are safe to wrap with click-to-field markers. */ + /** @var string[] Core resource fields wrapped using their own name as the field identifier. */ protected $wrappableFields = [ 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', ]; + /** @var array<string, string|null> Cache of TV name → "tv{id}" (or null if not a TV). */ + protected $tvIdCache = []; + /** * @param array|string $tag * @param bool $processUncacheable @@ -60,8 +67,15 @@ public function processTag($tag, $processUncacheable = true) $fieldName = explode(':', $fieldName)[0]; $fieldName = trim($fieldName); - if (!in_array($fieldName, $this->wrappableFields, true)) { - return parent::processTag($tag, $processUncacheable); + // Determine the marker field identifier: use the field name directly + // for known core fields, or look up "tv{id}" for TVs. + if (in_array($fieldName, $this->wrappableFields, true)) { + $markerField = $fieldName; + } else { + $markerField = $this->getTvFieldId($fieldName); + if ($markerField === null) { + return parent::processTag($tag, $processUncacheable); + } } $output = parent::processTag($tag, $processUncacheable); @@ -72,7 +86,23 @@ public function processTag($tag, $processUncacheable = true) return $output; } - return "\x02MMMP:{$fieldName}\x02{$output}\x03MMMP\x03"; + return "\x02MMMP:{$markerField}\x02{$output}\x03MMMP\x03"; + } + + /** + * Returns "tv{id}" for a TV identified by name, or null if not a TV. + * Results are cached on the instance to avoid repeated DB queries. + * + * @param string $name + * @return string|null + */ + private function getTvFieldId($name) + { + if (!array_key_exists($name, $this->tvIdCache)) { + $tv = $this->modx->getObject('modTemplateVar', ['name' => $name]); + $this->tvIdCache[$name] = $tv ? 'tv' . $tv->get('id') : null; + } + return $this->tvIdCache[$name]; } } } @@ -80,11 +110,14 @@ public function processTag($tag, $processUncacheable = true) // MODX 2 branch. class MagicPreviewCoreParser extends modParser { - /** @var string[] Core resource fields that are safe to wrap with click-to-field markers. */ + /** @var string[] Core resource fields wrapped using their own name as the field identifier. */ protected $wrappableFields = [ 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', ]; + /** @var array<string, string|null> Cache of TV name → "tv{id}" (or null if not a TV). */ + protected $tvIdCache = []; + /** * @param array|string $tag * @param bool $processUncacheable @@ -112,8 +145,13 @@ public function processTag($tag, $processUncacheable = true) $fieldName = explode(':', $fieldName)[0]; $fieldName = trim($fieldName); - if (!in_array($fieldName, $this->wrappableFields, true)) { - return parent::processTag($tag, $processUncacheable); + if (in_array($fieldName, $this->wrappableFields, true)) { + $markerField = $fieldName; + } else { + $markerField = $this->getTvFieldId($fieldName); + if ($markerField === null) { + return parent::processTag($tag, $processUncacheable); + } } $output = parent::processTag($tag, $processUncacheable); @@ -122,7 +160,20 @@ public function processTag($tag, $processUncacheable = true) return $output; } - return "\x02MMMP:{$fieldName}\x02{$output}\x03MMMP\x03"; + return "\x02MMMP:{$markerField}\x02{$output}\x03MMMP\x03"; + } + + /** + * @param string $name + * @return string|null + */ + private function getTvFieldId($name) + { + if (!array_key_exists($name, $this->tvIdCache)) { + $tv = $this->modx->getObject('modTemplateVar', ['name' => $name]); + $this->tvIdCache[$name] = $tv ? 'tv' . $tv->get('id') : null; + } + return $this->tvIdCache[$name]; } } } From 6950208a38cfc5e074a1e7934a1930d6d43ee8a2 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Fri, 26 Jun 2026 20:43:35 +0800 Subject: [PATCH 7/7] Don't wrap content field --- .../model/magicpreview/MagicPreviewCoreParser.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php index 4dadedd..51cf0d3 100644 --- a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php @@ -31,7 +31,7 @@ class MagicPreviewCoreParser extends \MODX\Revolution\modParser { /** @var string[] Core resource fields wrapped using their own name as the field identifier. */ protected $wrappableFields = [ - 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', + 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', ]; /** @var array<string, string|null> Cache of TV name → "tv{id}" (or null if not a TV). */ @@ -112,7 +112,7 @@ class MagicPreviewCoreParser extends modParser { /** @var string[] Core resource fields wrapped using their own name as the field identifier. */ protected $wrappableFields = [ - 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', 'content', + 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', ]; /** @var array<string, string|null> Cache of TV name → "tv{id}" (or null if not a TV). */