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/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/_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..9af7210 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,13 @@ a.mmmp-share-revoke:hover, a.mmmp-share-revoke:focus { color: #843534; } + +/* -------------------------------------------------------------------------- + Click-to-field: brief highlight on the manager field scrolled to from preview. + -------------------------------------------------------------------------- */ + +.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..b1b6ab5 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,27 @@ // 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) { + // 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 - 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 +665,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..f853756 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,116 @@ }); } + /** + * 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 - 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; + + var byData = document.querySelectorAll('[data-field="' + CSS.escape(field) + '"]'); + if (byData.length > idx) { + el = byData[idx]; + } + + 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); + } + } + + if (!el) { + var byName = document.querySelectorAll('[name="' + CSS.escape(field) + '"]'); + if (byName.length > idx) { + el = byName[idx]; + } + } + + if (!el) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + + // 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'); + 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 { + var scrollContainer = document.querySelector('#modx-content > .x-panel-body'); + if (!scrollContainer) { + 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 +1252,33 @@ return; } }, true); + + // ===================================================================== + // Click-to-field: postMessage listener + // ===================================================================== + + // Pre-compute once — origin never changes during the page session. + var previewOrigin = ''; + try { + previewOrigin = new URL(config().baseFrameUrl).origin; + } catch (ex) { + console.error('[MagicPreview] postMessage listener 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..c180248 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); } + + 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.class.php'; + } + $modx->parser = new MagicPreviewCoreParser($modx); + + $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( + '