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 , <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; } 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 @@ +<?php + +/** + * Temporary parser substitution used during preview generation. + * + * MODX's default parseProperties() collapses any array event param that has a + * 'value' key down to just that string — it's designed for element property sets + * where each property is stored as {value: '...', type: '...', ...}. That same + * logic mangles ContentBlocks_AfterParse's $phs, which is a plain associative + * array that may include a 'value' key (textarea, code, richtext field values). + * + * Installed temporarily on $modx->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 @@ +<?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; + + // 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<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/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); })() </script> {else}