From 0f6b11714949f52ab70b4d9dcc7ebfe0e6d7fbc2 Mon Sep 17 00:00:00 2001 From: Murray Wood Date: Fri, 12 Jun 2026 23:12:59 +0800 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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). */ From fcd647223cc1c46cd5727baf1ad6b9ff1de08314 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:10:54 +0800 Subject: [PATCH 08/15] Trim branch comments --- assets/components/magicpreview/css/mgr.css | 3 +- assets/components/magicpreview/js/preview.js | 33 ++++++------------- .../magicpreview/MagicPreviewCoreParser.php | 5 --- .../magicpreview/templates/preview.tpl | 4 +-- 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css index 3498506..9af7210 100644 --- a/assets/components/magicpreview/css/mgr.css +++ b/assets/components/magicpreview/css/mgr.css @@ -564,8 +564,7 @@ a.mmmp-share-revoke:focus { } /* -------------------------------------------------------------------------- - Click-to-field: brief highlight on the manager field scrolled to from the - preview. Transition fades the outline out after the initial flash. + Click-to-field: brief highlight on the manager field scrolled to from preview. -------------------------------------------------------------------------- */ .mmmp-field-highlight { diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index 8f5f540..6e3890c 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -905,33 +905,27 @@ }); } - // ========================================================================= - // 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. + * Scroll the manager resource form to the field identified by field/index, + * activating the correct tab first if needed. Resolves via data-field + * attribute (ContentBlocks), ExtJS component id (core fields), then name + * attribute (TVs). Falls back to scrolling page top if nothing matches. * - * @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. + * @param {string} field - Field identifier: CB numeric id, core field name + * (e.g. "pagetitle"), or TV name (e.g. "tv42"). + * @param {number} [index=0] - 0-based index when the same field 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 <li data-field="5"> var byData = document.querySelectorAll('[data-field="' + CSS.escape(field) + '"]'); if (byData.length > idx) { el = byData[idx]; } - // ExtJS component — MODX core resource fields (pagetitle, longtitle, etc.) if (!el) { try { var cmp = Ext.getCmp('modx-resource-' + field); @@ -943,7 +937,6 @@ } } - // name attribute — plain inputs and TV fields if (!el) { var byName = document.querySelectorAll('[name="' + CSS.escape(field) + '"]'); if (byName.length > idx) { @@ -951,15 +944,13 @@ } } - // 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. + // ExtJS keeps inactive tab content in the DOM (hidden via CSS), so + // dom.contains() finds elements regardless of which tab is active. var needsTabSwitch = false; try { var tabPanel = Ext.getCmp('modx-resource-tabs'); @@ -983,10 +974,8 @@ 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); @@ -1266,8 +1255,6 @@ // ===================================================================== // 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. diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php index 51cf0d3..192d33b 100644 --- a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php @@ -45,7 +45,6 @@ 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) === '!') { @@ -57,8 +56,6 @@ public function processTag($tag, $processUncacheable = true) 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); @@ -67,8 +64,6 @@ public function processTag($tag, $processUncacheable = true) $fieldName = explode(':', $fieldName)[0]; $fieldName = trim($fieldName); - // 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 { diff --git a/core/components/magicpreview/templates/preview.tpl b/core/components/magicpreview/templates/preview.tpl index 5deab84..e6078cc 100644 --- a/core/components/magicpreview/templates/preview.tpl +++ b/core/components/magicpreview/templates/preview.tpl @@ -112,9 +112,7 @@ 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). + // In window mode window.top is this popup, not the manager — relay to window.opener. var relayOrigin = ''; try { relayOrigin = new URL(document.getElementById('mmmp-js-frame-inner').src).origin; } catch (ex) {} window.addEventListener('message', function(e) { From a71ff44003fba5acd6a68616f413a8a7e0af7ff1 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:10:58 +0800 Subject: [PATCH 09/15] Add click_to_field setting to gate click-to-field feature --- _build/data/settings.php | 5 +++ .../elements/plugins/magicpreview.plugin.php | 33 +++++++------------ .../magicpreview/lexicon/da/default.inc.php | 2 ++ .../magicpreview/lexicon/de/default.inc.php | 2 ++ .../magicpreview/lexicon/en/default.inc.php | 2 ++ .../processors/resource/PreviewTrait.php | 17 +++++----- 6 files changed, 30 insertions(+), 31 deletions(-) diff --git a/_build/data/settings.php b/_build/data/settings.php index 1315250..44048b1 100644 --- a/_build/data/settings.php +++ b/_build/data/settings.php @@ -55,6 +55,11 @@ 'value' => '604800', 'xtype' => 'numberfield', ], + 'click_to_field' => [ + 'area' => 'Preview', + 'value' => true, + 'xtype' => 'combo-boolean', + ], 'icon_save_draft' => [ 'area' => 'Icons', 'value' => '', diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index c684618..21e99f1 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -266,21 +266,15 @@ $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); + if ($modx->getOption('magicpreview.click_to_field', null, true)) { + // No restoration 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 - // are present. Template authors can also add the attributes manually. - $modx->regClientStartupHTMLBlock('<style> + $modx->regClientStartupHTMLBlock('<style> [data-magicpreview-field]{cursor:pointer;} [data-magicpreview-field]:hover{outline:2px dashed rgba(52,152,219,0.6);outline-offset:2px;} </style> @@ -293,6 +287,7 @@ window.top.postMessage({type:"magicpreview:scrollToField",field:el.getAttribute("data-magicpreview-field"),index:parseInt(el.getAttribute("data-magicpreview-idx")||"0",10)},window.location.origin); }); </script>'); + } break; @@ -331,16 +326,13 @@ 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 <head>, 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 <head>...</head> (covers <title>, <meta>, <link>). + // Three passes: strip from <head>, strip from HTML opening-tag attribute values, + // then convert what remains in body text to click-to-field spans. $output = preg_replace_callback( '/(<head[^>]*>)(.*?)(<\/head>)/si', function ($m) { @@ -351,8 +343,6 @@ function ($m) { $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) { @@ -361,7 +351,6 @@ function ($m) { $output ); - // Convert remaining markers (body text contexts) to click-to-field spans. $output = preg_replace_callback( "/\x02MMMP:([^\x02]*)\x02(.*?)\x03MMMP\x03/s", function ($m) { diff --git a/core/components/magicpreview/lexicon/da/default.inc.php b/core/components/magicpreview/lexicon/da/default.inc.php index bf7eeaf..24aa4f5 100644 --- a/core/components/magicpreview/lexicon/da/default.inc.php +++ b/core/components/magicpreview/lexicon/da/default.inc.php @@ -31,6 +31,8 @@ $_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.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.'; +$_lang['setting_magicpreview.click_to_field'] = 'Klik til felt'; +$_lang['setting_magicpreview.click_to_field_desc'] = 'Når aktiveret, ruller et klik på et felt i forhåndsvisningen ressourceformularen til det pågældende felt. Kræver ContentBlocks eller en skabelon, der tilføjer data-magicpreview-field-attributter. Standard: Ja.'; $_lang['setting_magicpreview.template_filter_mode'] = 'Skabelon-filtertilstand'; $_lang['setting_magicpreview.template_filter_mode_desc'] = 'Begrænser hvor Forhåndsvisnings-knappen vises baseret på skabelon-ID. "None" viser knappen på alle ressourcer (standard). "Block Listed" skjuler knappen på ressourcer, der bruger et skabelon-ID på listen i "Skabelon-filter-ID\'er". "Allow Listed Only" viser kun knappen på ressourcer med et skabelon-ID på listen (en tom liste skjuler den overalt).'; $_lang['setting_magicpreview.template_filter_ids'] = 'Skabelon-filter-ID\'er'; diff --git a/core/components/magicpreview/lexicon/de/default.inc.php b/core/components/magicpreview/lexicon/de/default.inc.php index a71a11e..88715d5 100644 --- a/core/components/magicpreview/lexicon/de/default.inc.php +++ b/core/components/magicpreview/lexicon/de/default.inc.php @@ -30,6 +30,8 @@ $_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.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.'; +$_lang['setting_magicpreview.click_to_field'] = 'Klick zum Feld'; +$_lang['setting_magicpreview.click_to_field_desc'] = 'Wenn aktiviert, scrollt ein Klick auf ein Feld in der Vorschau das Ressourcenformular zu diesem Feld. Erfordert ContentBlocks oder ein Template, das data-magicpreview-field-Attribute setzt. Standard: Ja.'; $_lang['setting_magicpreview.template_filter_mode'] = 'Vorlagen-Filtermodus'; $_lang['setting_magicpreview.template_filter_mode_desc'] = 'Begrenzt, wo der Vorschau-Button erscheint, basierend auf der Vorlagen-ID. "None" zeigt den Button für alle Ressourcen (Standard). "Block Listed" blendet den Button für Ressourcen mit einer in "Vorlagen-Filter-IDs" aufgelisteten Vorlagen-ID aus. "Allow Listed Only" zeigt den Button nur für Ressourcen mit aufgelisteter Vorlagen-ID (leere Liste blendet ihn überall aus).'; $_lang['setting_magicpreview.template_filter_ids'] = 'Vorlagen-Filter-IDs'; diff --git a/core/components/magicpreview/lexicon/en/default.inc.php b/core/components/magicpreview/lexicon/en/default.inc.php index ff9b763..65d99cf 100644 --- a/core/components/magicpreview/lexicon/en/default.inc.php +++ b/core/components/magicpreview/lexicon/en/default.inc.php @@ -31,6 +31,8 @@ $_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.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.'; +$_lang['setting_magicpreview.click_to_field'] = 'Click to Field'; +$_lang['setting_magicpreview.click_to_field_desc'] = 'When enabled, clicking a field in the preview panel scrolls the resource form to that field. Requires ContentBlocks or a template that adds data-magicpreview-field attributes. Default: Yes.'; $_lang['setting_magicpreview.template_filter_mode'] = 'Template Filter Mode'; $_lang['setting_magicpreview.template_filter_mode_desc'] = 'Restrict where the Preview button appears based on template ID. "None" shows the button on every resource (default). "Block Listed" hides the button on resources using a template ID listed in Template Filter IDs. "Allow Listed Only" shows the button only on resources using a listed template ID (an empty list will hide it everywhere).'; $_lang['setting_magicpreview.template_filter_ids'] = 'Template Filter IDs'; diff --git a/core/components/magicpreview/processors/resource/PreviewTrait.php b/core/components/magicpreview/processors/resource/PreviewTrait.php index cdf9a1b..eda7b50 100644 --- a/core/components/magicpreview/processors/resource/PreviewTrait.php +++ b/core/components/magicpreview/processors/resource/PreviewTrait.php @@ -18,14 +18,9 @@ public function fireBeforeSaveEvent() { $service = $this->getMagicPreviewService(); - // 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_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. + // 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)) { @@ -33,9 +28,13 @@ public function fireBeforeSaveEvent() } $savedParser = $this->modx->parser; $this->modx->parser = new MagicPreviewContentBlocksParser($this->modx); + // Clear the element cache so every ContentBlocks_AfterParse execution runs fresh + // rather than returning a cached (empty) event output. $savedElementCache = $this->modx->elementCache; $this->modx->elementCache = []; - $service->addFieldMarkers = true; + if ($this->modx->getOption('magicpreview.click_to_field', null, true)) { + $service->addFieldMarkers = true; + } try { $this->modx->invokeEvent('OnResourceMagicPreview', [ 'resource' => $this->object, From 64606a2cb6ef15f9b15ebb3cf4f5167e4ce0f29a Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:35:26 +0800 Subject: [PATCH 10/15] Fix window-mode relay: compute frameOrigin lazily inside message handler --- core/components/magicpreview/templates/preview.tpl | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/components/magicpreview/templates/preview.tpl b/core/components/magicpreview/templates/preview.tpl index e6078cc..4d17033 100644 --- a/core/components/magicpreview/templates/preview.tpl +++ b/core/components/magicpreview/templates/preview.tpl @@ -113,14 +113,18 @@ }); // In window mode window.top is this popup, not the manager — relay to window.opener. - var relayOrigin = ''; - try { relayOrigin = new URL(document.getElementById('mmmp-js-frame-inner').src).origin; } catch (ex) {} + // relayOrigin is read lazily inside the handler: at page load the inner iframe + // src is '' (refreshFrame sets it to '' until a real hash arrives), so computing + // it eagerly would always fail and permanently block the relay. window.addEventListener('message', function(e) { var data = e.data; if (!data || typeof data !== 'object' || data.type !== 'magicpreview:scrollToField') { return; } - if (!relayOrigin || e.origin !== relayOrigin) { + var innerFrame = document.getElementById('mmmp-js-frame-inner'); + var frameOrigin = ''; + try { frameOrigin = new URL(innerFrame ? innerFrame.src : '').origin; } catch (ex) {} + if (!frameOrigin || e.origin !== frameOrigin) { return; } if (window.opener && !window.opener.closed) { From dafa826555c71130d4f4d5136729ddcf078cf05a Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:35:30 +0800 Subject: [PATCH 11/15] Fix postMessage targetOrigin: use * instead of frontend's own origin --- .../magicpreview/elements/plugins/magicpreview.plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index 21e99f1..a26ccda 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -284,7 +284,7 @@ if (!el) { return; } - window.top.postMessage({type:"magicpreview:scrollToField",field:el.getAttribute("data-magicpreview-field"),index:parseInt(el.getAttribute("data-magicpreview-idx")||"0",10)},window.location.origin); + window.top.postMessage({type:"magicpreview:scrollToField",field:el.getAttribute("data-magicpreview-field"),index:parseInt(el.getAttribute("data-magicpreview-idx")||"0",10)},"*"); }); </script>'); } From 8aa4c3613b27a540f928b716fc7b4c0921f78fac Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:35:33 +0800 Subject: [PATCH 12/15] Fix relayoutModx: save _originalGetViewSize in setTimeout branch too --- assets/components/magicpreview/js/panel.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/components/magicpreview/js/panel.js b/assets/components/magicpreview/js/panel.js index ab75d02..b1b6ab5 100644 --- a/assets/components/magicpreview/js/panel.js +++ b/assets/components/magicpreview/js/panel.js @@ -562,6 +562,11 @@ var pwInner = panelIsOpenInner ? getPanelWidth() : 0; var needsOverrideInner = panelIsOpenInner || bannerHInner > 0; if (needsOverrideInner) { + // bannerH may have been 0 when the outer block ran (not yet painted), + // so _originalGetViewSize may not have been saved yet — save it now. + if (!_originalGetViewSize) { + _originalGetViewSize = layout.el.getViewSize.bind(layout.el); + } layout.el.getViewSize = function() { return { width: window.innerWidth - pwInner, From c0c35c68a1be2c20d3940a4b8dd5743f5dadc3a6 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:50:50 +0800 Subject: [PATCH 13/15] Split parser classes into .class.php + trait per naming convention --- .../elements/plugins/magicpreview.plugin.php | 2 +- ...MagicPreviewContentBlocksParser.class.php} | 28 +-- .../MagicPreviewContentBlocksParserTrait.php | 17 ++ .../MagicPreviewCoreParser.class.php | 43 +++++ .../magicpreview/MagicPreviewCoreParser.php | 174 ------------------ .../MagicPreviewCoreParserTrait.php | 75 ++++++++ .../processors/resource/PreviewTrait.php | 2 +- 7 files changed, 141 insertions(+), 200 deletions(-) rename core/components/magicpreview/model/magicpreview/{MagicPreviewContentBlocksParser.php => MagicPreviewContentBlocksParser.class.php} (59%) create mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParserTrait.php create mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.class.php delete mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php create mode 100644 core/components/magicpreview/model/magicpreview/MagicPreviewCoreParserTrait.php diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index a26ccda..8e257a8 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -270,7 +270,7 @@ // No restoration needed — the request ends after the page is rendered. $modx->getParser(); if (!class_exists('MagicPreviewCoreParser', false)) { - require_once $service->config['modelPath'] . 'magicpreview/MagicPreviewCoreParser.php'; + require_once $service->config['modelPath'] . 'magicpreview/MagicPreviewCoreParser.class.php'; } $modx->parser = new MagicPreviewCoreParser($modx); diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.class.php similarity index 59% rename from core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php rename to core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.class.php index d4f68c9..6462b03 100644 --- a/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.php +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParser.class.php @@ -16,40 +16,20 @@ * through its own cbParser swap cycle. */ +require_once __DIR__ . '/MagicPreviewContentBlocksParserTrait.php'; + // 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); - } + use MagicPreviewContentBlocksParserTrait; } } } 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); - } + use MagicPreviewContentBlocksParserTrait; } } diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParserTrait.php b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParserTrait.php new file mode 100644 index 0000000..88dc3f0 --- /dev/null +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewContentBlocksParserTrait.php @@ -0,0 +1,17 @@ +<?php + +trait MagicPreviewContentBlocksParserTrait +{ + /** @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/MagicPreviewCoreParser.class.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.class.php new file mode 100644 index 0000000..6ca74db --- /dev/null +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.class.php @@ -0,0 +1,43 @@ +<?php + +/** + * Frontend parser substitution used during preview page rendering. + * + * 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 + * plugin handler then resolves them: markers inside <head>, <script>, <style>, + * 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. + * + * 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. + */ + +require_once __DIR__ . '/MagicPreviewCoreParserTrait.php'; + +// 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 + { + use MagicPreviewCoreParserTrait; + } + } +} elseif (!class_exists('MagicPreviewCoreParser', false)) { + // MODX 2 branch. + class MagicPreviewCoreParser extends modParser + { + use MagicPreviewCoreParserTrait; + } +} diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php deleted file mode 100644 index 192d33b..0000000 --- a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php +++ /dev/null @@ -1,174 +0,0 @@ -<?php - -/** - * Frontend parser substitution used during preview page rendering. - * - * 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 - * 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. - * - * 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. - */ - -// 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 wrapped using their own name as the field identifier. */ - protected $wrappableFields = [ - 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', - ]; - - /** @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 - */ - 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)) { - $markerField = $fieldName; - } else { - $markerField = $this->getTvFieldId($fieldName); - if ($markerField === null) { - 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:{$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]; - } - } - } -} elseif (!class_exists('MagicPreviewCoreParser', false)) { - // MODX 2 branch. - 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', - ]; - - /** @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 - */ - 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)) { - $markerField = $fieldName; - } else { - $markerField = $this->getTvFieldId($fieldName); - if ($markerField === null) { - return parent::processTag($tag, $processUncacheable); - } - } - - $output = parent::processTag($tag, $processUncacheable); - - if (!is_string($output) || $output === '' || substr($output, 0, 2) === '[[') { - return $output; - } - - 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]; - } - } -} diff --git a/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParserTrait.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParserTrait.php new file mode 100644 index 0000000..c8013a7 --- /dev/null +++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParserTrait.php @@ -0,0 +1,75 @@ +<?php + +trait MagicPreviewCoreParserTrait +{ + /** @var string[] Core resource fields wrapped using their own name as the field identifier. */ + protected $wrappableFields = [ + 'pagetitle', 'longtitle', 'description', 'menutitle', 'introtext', + ]; + + /** @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 + */ + 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)) { + $markerField = $fieldName; + } else { + $markerField = $this->getTvFieldId($fieldName); + if ($markerField === null) { + 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:{$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]; + } +} diff --git a/core/components/magicpreview/processors/resource/PreviewTrait.php b/core/components/magicpreview/processors/resource/PreviewTrait.php index eda7b50..15daaba 100644 --- a/core/components/magicpreview/processors/resource/PreviewTrait.php +++ b/core/components/magicpreview/processors/resource/PreviewTrait.php @@ -24,7 +24,7 @@ public function fireBeforeSaveEvent() // ContentBlocks' loadParser()/restoreParser() correctly preserves this instance. $this->modx->getParser(); if (!class_exists('MagicPreviewContentBlocksParser', false)) { - require_once __DIR__ . '/../../model/magicpreview/MagicPreviewContentBlocksParser.php'; + require_once __DIR__ . '/../../model/magicpreview/MagicPreviewContentBlocksParser.class.php'; } $savedParser = $this->modx->parser; $this->modx->parser = new MagicPreviewContentBlocksParser($this->modx); From 386256f1ffca1bec298970df5fc4ff1c13084b26 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:51:14 +0800 Subject: [PATCH 14/15] Fix OnWebPagePrerender: handle script/style bodies and quoted attribute > --- .../elements/plugins/magicpreview.plugin.php | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php index 8e257a8..c180248 100644 --- a/core/components/magicpreview/elements/plugins/magicpreview.plugin.php +++ b/core/components/magicpreview/elements/plugins/magicpreview.plugin.php @@ -331,8 +331,9 @@ break; } - // Three passes: strip from <head>, strip from HTML opening-tag attribute values, - // then convert what remains in body text to click-to-field spans. + // Four passes: strip from <head>, strip from <script>/<style> bodies, + // strip from HTML opening-tag attribute values, then convert what + // remains in body text to click-to-field spans. $output = preg_replace_callback( '/(<head[^>]*>)(.*?)(<\/head>)/si', function ($m) { @@ -343,8 +344,21 @@ function ($m) { $output ); + // Strip from <script> and <style> bodies in the page body — a marker + // inside a JS string literal would otherwise become a <span> tag. $output = preg_replace_callback( - '/<[a-zA-Z][^>]*>/s', + '/<(script|style)[^>]*>.*?<\/\1>/si', + function ($m) { + return preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[0]); + }, + $output + ); + + // Strip from HTML opening tags. The regex handles quoted attribute values + // so a literal > inside an attribute (e.g. content="a > b") does not + // cause early termination and leave a marker tail in body-text position. + $output = preg_replace_callback( + '/<[a-zA-Z][^>"\']*(?:"[^"]*"|\'[^\']*\'|[^>])*>/s', function ($m) { return preg_replace("/\x02MMMP:[^\x02]*\x02(.*?)\x03MMMP\x03/s", '$1', $m[0]); }, From 2b8d6560a349a06f8a8168bfd5bb89348c922c94 Mon Sep 17 00:00:00 2001 From: Murray Wood <murray@digitalpenguin.studio> Date: Sat, 27 Jun 2026 10:51:20 +0800 Subject: [PATCH 15/15] Fix misleading error label in postMessage listener setup --- assets/components/magicpreview/js/preview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/components/magicpreview/js/preview.js b/assets/components/magicpreview/js/preview.js index 6e3890c..f853756 100644 --- a/assets/components/magicpreview/js/preview.js +++ b/assets/components/magicpreview/js/preview.js @@ -1262,7 +1262,7 @@ try { previewOrigin = new URL(config().baseFrameUrl).origin; } catch (ex) { - console.error('[MagicPreview] scrollToField error:', ex); + console.error('[MagicPreview] postMessage listener error:', ex); } window.addEventListener('message', function(e) {