/** * MetaSync HTML Visual Editor * * Initializes GrapesJS editor with custom configuration * for editing raw HTML pages with full visual capabilities. * * @global grapesjs - Loaded from GrapesJS library script * @global metasyncEditor - Injected via wp_localize_script */ /* eslint-env browser */ /* global grapesjs, metasyncEditor */ (function($) { 'use strict'; let editor; let hasUnsavedChanges = false; $(document).ready(function() { // Bind the header controls first so Back and Preview keep working even // if the canvas never starts. initializeEventHandlers(); if (!startEditor()) { return; } preventAccidentalExit(); }); /** * Start the editor, reporting any dependency or start-up failure to the * user instead of leaving an unexplained empty canvas behind. * * @return {boolean} True when the canvas initialized. */ function startEditor() { // Dependencies absent from disk (reported by PHP) plus any whose // request failed in the browser (blocked by CSP, an extension, etc). const missing = (metasyncEditor.missing || []).slice(); const failed = (window.metasyncEditorAssets && window.metasyncEditorAssets.failed) || []; failed.forEach(function(name) { if (missing.indexOf(name) === -1) { missing.push(name); } }); // The editor script is ordered after GrapesJS but WordPress cannot // guarantee the file actually arrived, so verify the global exists. if (typeof grapesjs === 'undefined') { if (missing.indexOf('grapesjs') === -1) { missing.push('grapesjs'); } showLoadFailure( metasyncEditor.i18n.load_failed_core, missing, { fatal: true } ); return false; } try { initializeEditor(); } catch (err) { console.error('MetaSync HTML Editor failed to initialize:', err); showLoadFailure( metasyncEditor.i18n.load_failed_init, missing, { fatal: true } ); return false; } // The blocks library is optional: without it the canvas still loads and // the page stays editable, only the extra block palette is missing. if (!resolveBlocksPlugin()) { if (missing.indexOf('grapesjs-blocks-basic') === -1) { missing.push('grapesjs-blocks-basic'); } showLoadFailure( metasyncEditor.i18n.load_failed_blocks, missing, { fatal: false } ); } // Pages the canvas cannot round-trip are announced after it starts, so // the layout stays visible for reference, but saving is disabled before // the user can edit: the document was already reduced when it was // parsed into the canvas, and saving that reduction is the data loss. showBlockedNotice(); return true; } /** * Disable saving for a page the canvas cannot represent faithfully. * * GrapesJS builds body fragments. A complete document, or a page carrying * scripts, loses whatever the canvas cannot hold the moment it is parsed — * so the warning belongs at load, not at save, and the only honest action * is to send the user to the editor that can save the page intact. */ function showBlockedNotice() { const reason = metasyncEditor.blocked_reason; if (!reason) { return; } const $panel = $('#metasync-editor-load-failure'); const message = reason === 'lps' ? t('blocked_lps', 'This page was imported from Website Studio, which owns its content.') : t('blocked_full_document', 'This page is a complete HTML document, so saving here would discard its doctype, head and scripts.'); $('.metasync-save-button') .prop('disabled', true) .attr('title', t('blocked_save_disabled', 'Saving is disabled to protect this page from being rewritten')); if (!$panel.length) { return; } $panel.find('.metasync-load-failure-title') .text(t('blocked_title', 'This page cannot be saved from the visual editor')); $panel.find('.metasync-load-failure-message').text(message); $panel.find('.metasync-load-failure-detail').hide(); // Non-fatal styling: the canvas stays visible and usable for reading, // so the notice sits above it rather than covering it. $panel.removeClass('metasync-load-failure-fatal'); // Reload cannot help here — the block is a property of the stored // page — so that button becomes the route to the lossless editor. const $primary = $panel.find('.metasync-load-failure-reload').off('click'); if (metasyncEditor.direct_edit_url) { $primary .text(t('open_direct_editor', 'Edit HTML Directly')) .on('click', function(e) { e.preventDefault(); window.location.href = metasyncEditor.direct_edit_url; }) .show(); } else { $primary.hide(); } $panel.find('.metasync-load-failure-dismiss') .text(t('dismiss', 'Dismiss')) .off('click') .on('click', function(e) { e.preventDefault(); $panel.attr('hidden', 'hidden'); }) .show(); $panel.removeAttr('hidden'); updateStatus('error'); } /** * Resolve the grapesjs-blocks-basic plugin. * * Releases before 1.0.0 registered themselves under the name * 'gjs-blocks-basic'; 1.0.x only exposes a UMD export, so the plugin is * looked up by reference and the legacy registry name is only a fallback. * * @return {Function|undefined} The plugin function when available. */ function resolveBlocksPlugin() { const exported = window['gjs-blocks-basic']; const plugin = exported && exported.default ? exported.default : exported; if (typeof plugin === 'function') { return plugin; } if (grapesjs.plugins && typeof grapesjs.plugins.get === 'function') { return grapesjs.plugins.get('gjs-blocks-basic'); } return undefined; } /** * Report an asset-loading or start-up failure in the page. * * Only dependency names and the localized message are shown; no paths, * versions or server detail are exposed. * * @param {string} message Localized explanation. * @param {Array} missing Names of the dependencies that did not load. * @param {Object} options Set fatal to true when the canvas is unusable. */ function showLoadFailure(message, missing, options) { const settings = options || {}; const $panel = $('#metasync-editor-load-failure'); console.error('MetaSync HTML Editor: ' + message + (missing.length ? ' (' + missing.join(', ') + ')' : '')); if (!$panel.length) { return; } $panel.find('.metasync-load-failure-title').text(metasyncEditor.i18n.load_failed_title); $panel.find('.metasync-load-failure-message').text(message); const $detail = $panel.find('.metasync-load-failure-detail'); if (missing.length) { $detail.text( (metasyncEditor.i18n.load_failed_detail || '%s').replace('%s', missing.join(', ')) ).show(); } else { $detail.hide(); } $panel.find('.metasync-load-failure-reload') .text(metasyncEditor.i18n.reload) .off('click') .on('click', function(e) { e.preventDefault(); window.location.reload(); }); $panel.toggleClass('metasync-load-failure-fatal', !!settings.fatal); // Clear `hidden` rather than calling .show(), which would set an inline // display and break the panel's flex centering. $panel.removeAttr('hidden'); if (settings.fatal) { $('.metasync-save-button') .prop('disabled', true) .attr('title', metasyncEditor.i18n.save_disabled); } else { // Non-fatal: let the notice be dismissed and keep editing. $panel.find('.metasync-load-failure-dismiss') .text(metasyncEditor.i18n.dismiss) .off('click') .on('click', function(e) { e.preventDefault(); $panel.hide(); }) .show(); } updateStatus('error'); } /** * Sidebar panels, in switcher order. * * Each entry maps a switcher button to the sidebar panel it reveals, the * command that activates it, and the localized-label key for its title. * * @type {Array} */ const SIDEBAR_PANELS = [ { id: 'styles', command: 'show-styles', icon: 'dashicons-art', labelKey: 'panel_styles', fallback: 'Styles' }, { id: 'traits', command: 'show-traits', icon: 'dashicons-admin-generic', labelKey: 'panel_settings', fallback: 'Settings' }, { id: 'layers', command: 'show-layers', icon: 'dashicons-menu', labelKey: 'panel_layers', fallback: 'Layers' }, { id: 'blocks', command: 'show-blocks', icon: 'dashicons-grid-view', labelKey: 'panel_blocks', fallback: 'Blocks' } ]; /** * Resolve a localized string with an English fallback. * * @param {string} key Property under metasyncEditor.i18n. * @param {string} fallback Used when the payload predates the key. * @return {string} */ function t(key, fallback) { return (metasyncEditor.i18n && metasyncEditor.i18n[key]) || fallback; } /** * Show a transient action notice in the page's live region. * * alert() blocks the UI thread, cannot be styled or dismissed by * keyboard, and is unreachable to screen-reader users mid-flow; the * notice element is announced automatically instead. * * @param {string} message Localized message to display. */ let noticeTimer = null; function showNotice(message) { const $notice = $('#metasync-editor-notice'); if (!$notice.length) { window.alert(message); return; } $notice.text(message).removeAttr('hidden'); if (noticeTimer) { clearTimeout(noticeTimer); } noticeTimer = setTimeout(function() { $notice.attr('hidden', true); }, 6000); } /** * Render the sidebar's panel switcher buttons. * * The buttons are created in the sidebar itself rather than as GrapesJS * panel buttons, so the editor re-rendering its panels cannot detach them. */ function buildPanelSwitcher() { const $switcher = $('#metasync-editor-panel-switcher'); if (!$switcher.length) { return; } $switcher.empty(); SIDEBAR_PANELS.forEach(function(panel) { const label = t(panel.labelKey, panel.fallback); const $button = $('