diff --git a/_bootstrap/index.php b/_bootstrap/index.php
index 1c910bb..285efbe 100644
--- a/_bootstrap/index.php
+++ b/_bootstrap/index.php
@@ -125,6 +125,20 @@
], ['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',
+ '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..2d87a8e 100644
--- a/_build/events/events.magicpreview.php
+++ b/_build/events/events.magicpreview.php
@@ -7,6 +7,8 @@
'OnDocFormSave',
'OnLoadWebDocument',
'OnManagerPageBeforeRender',
+ 'OnWebPagePrerender',
+ 'ContentBlocks_AfterParse',
];
foreach ($e as $ev) {
diff --git a/assets/components/magicpreview/css/mgr.css b/assets/components/magicpreview/css/mgr.css
index 02fff6a..3498506 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;
@@ -519,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/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..8f5f540 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) {
@@ -908,6 +905,127 @@
});
}
+ // =========================================================================
+ // 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];
+ }
+
+ // 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' });
+ 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
// =========================================================================
@@ -1145,5 +1263,35 @@
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..c684618 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);
}
+
+ // 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
+ // are present. Template authors can also add the attributes manually.
+ $modx->regClientStartupHTMLBlock('
+');
+
+ break;
+
+ 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(
+ '
'
+ . $tpl
+ . '
'
+ );
+ 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 , , ).
+ $output = preg_replace_callback(
+ '/(]*>)(.*?)(<\/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
+ // or ).
+ $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 ''
+ . $m[2]
+ . '';
+ },
+ $output
+ );
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/MagicPreviewCoreParser.php b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php
new file mode 100644
index 0000000..51cf0d3
--- /dev/null
+++ b/core/components/magicpreview/model/magicpreview/MagicPreviewCoreParser.php
@@ -0,0 +1,179 @@
+ or HTML attribute
+ * contexts are stripped (leaving just the value); markers in body content become
+ * 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 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;
+
+ // 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);
+
+ // 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);
+
+ // 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 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/magicpreview.class.php b/core/components/magicpreview/model/magicpreview/magicpreview.class.php
index c4e43a0..e8717cc 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_AfterParse 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..cdf9a1b 100644
--- a/core/components/magicpreview/processors/resource/PreviewTrait.php
+++ b/core/components/magicpreview/processors/resource/PreviewTrait.php
@@ -16,11 +16,36 @@ 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_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', [
+ 'resource' => $this->object,
+ 'properties' => $this->getProperties(),
+ ]);
+ } finally {
+ $service->addFieldMarkers = false;
+ $this->modx->elementCache = $savedElementCache;
+ $this->modx->parser = $savedParser;
+ }
$this->failedSuccessfully = true;
@@ -38,8 +63,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);
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}