PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / admin / js / metasync-html-editor.js

metasync-html-editor.js in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at admin/js/metasync-html-editor.js

1,156 lines 46.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * MetaSync HTML Visual Editor
3 *
4 * Initializes GrapesJS editor with custom configuration
5 * for editing raw HTML pages with full visual capabilities.
6 *
7 * @global grapesjs - Loaded from GrapesJS library script
8 * @global metasyncEditor - Injected via wp_localize_script
9 */
10 /* eslint-env browser */
11 /* global grapesjs, metasyncEditor */
12
13 (function($) {
14 'use strict';
15
16 let editor;
17 let hasUnsavedChanges = false;
18
19 $(document).ready(function() {
20 // Bind the header controls first so Back and Preview keep working even
21 // if the canvas never starts.
22 initializeEventHandlers();
23
24 if (!startEditor()) {
25 return;
26 }
27
28 preventAccidentalExit();
29 });
30
31 /**
32 * Start the editor, reporting any dependency or start-up failure to the
33 * user instead of leaving an unexplained empty canvas behind.
34 *
35 * @return {boolean} True when the canvas initialized.
36 */
37 function startEditor() {
38 // Dependencies absent from disk (reported by PHP) plus any whose
39 // request failed in the browser (blocked by CSP, an extension, etc).
40 const missing = (metasyncEditor.missing || []).slice();
41 const failed = (window.metasyncEditorAssets && window.metasyncEditorAssets.failed) || [];
42
43 failed.forEach(function(name) {
44 if (missing.indexOf(name) === -1) {
45 missing.push(name);
46 }
47 });
48 // The editor script is ordered after GrapesJS but WordPress cannot
49 // guarantee the file actually arrived, so verify the global exists.
50 if (typeof grapesjs === 'undefined') {
51 if (missing.indexOf('grapesjs') === -1) {
52 missing.push('grapesjs');
53 }
54 showLoadFailure(
55 metasyncEditor.i18n.load_failed_core,
56 missing,
57 { fatal: true }
58 );
59 return false;
60 }
61
62 try {
63 initializeEditor();
64 } catch (err) {
65 console.error('MetaSync HTML Editor failed to initialize:', err);
66 showLoadFailure(
67 metasyncEditor.i18n.load_failed_init,
68 missing,
69 { fatal: true }
70 );
71 return false;
72 }
73
74 // The blocks library is optional: without it the canvas still loads and
75 // the page stays editable, only the extra block palette is missing.
76 if (!resolveBlocksPlugin()) {
77 if (missing.indexOf('grapesjs-blocks-basic') === -1) {
78 missing.push('grapesjs-blocks-basic');
79 }
80 showLoadFailure(
81 metasyncEditor.i18n.load_failed_blocks,
82 missing,
83 { fatal: false }
84 );
85 }
86
87 // Pages the canvas cannot round-trip are announced after it starts, so
88 // the layout stays visible for reference, but saving is disabled before
89 // the user can edit: the document was already reduced when it was
90 // parsed into the canvas, and saving that reduction is the data loss.
91 showBlockedNotice();
92
93 return true;
94 }
95
96 /**
97 * Disable saving for a page the canvas cannot represent faithfully.
98 *
99 * GrapesJS builds body fragments. A complete document, or a page carrying
100 * scripts, loses whatever the canvas cannot hold the moment it is parsed —
101 * so the warning belongs at load, not at save, and the only honest action
102 * is to send the user to the editor that can save the page intact.
103 */
104 function showBlockedNotice() {
105 const reason = metasyncEditor.blocked_reason;
106
107 if (!reason) {
108 return;
109 }
110
111 const $panel = $('#metasync-editor-load-failure');
112
113 const message = reason === 'lps'
114 ? t('blocked_lps', 'This page was imported from Website Studio, which owns its content.')
115 : t('blocked_full_document', 'This page is a complete HTML document, so saving here would discard its doctype, head and scripts.');
116
117 $('.metasync-save-button')
118 .prop('disabled', true)
119 .attr('title', t('blocked_save_disabled', 'Saving is disabled to protect this page from being rewritten'));
120
121 if (!$panel.length) {
122 return;
123 }
124
125 $panel.find('.metasync-load-failure-title')
126 .text(t('blocked_title', 'This page cannot be saved from the visual editor'));
127 $panel.find('.metasync-load-failure-message').text(message);
128 $panel.find('.metasync-load-failure-detail').hide();
129
130 // Non-fatal styling: the canvas stays visible and usable for reading,
131 // so the notice sits above it rather than covering it.
132 $panel.removeClass('metasync-load-failure-fatal');
133
134 // Reload cannot help here — the block is a property of the stored
135 // page — so that button becomes the route to the lossless editor.
136 const $primary = $panel.find('.metasync-load-failure-reload').off('click');
137
138 if (metasyncEditor.direct_edit_url) {
139 $primary
140 .text(t('open_direct_editor', 'Edit HTML Directly'))
141 .on('click', function(e) {
142 e.preventDefault();
143 window.location.href = metasyncEditor.direct_edit_url;
144 })
145 .show();
146 } else {
147 $primary.hide();
148 }
149
150 $panel.find('.metasync-load-failure-dismiss')
151 .text(t('dismiss', 'Dismiss'))
152 .off('click')
153 .on('click', function(e) {
154 e.preventDefault();
155 $panel.attr('hidden', 'hidden');
156 })
157 .show();
158
159 $panel.removeAttr('hidden');
160 updateStatus('error');
161 }
162
163 /**
164 * Resolve the grapesjs-blocks-basic plugin.
165 *
166 * Releases before 1.0.0 registered themselves under the name
167 * 'gjs-blocks-basic'; 1.0.x only exposes a UMD export, so the plugin is
168 * looked up by reference and the legacy registry name is only a fallback.
169 *
170 * @return {Function|undefined} The plugin function when available.
171 */
172 function resolveBlocksPlugin() {
173 const exported = window['gjs-blocks-basic'];
174 const plugin = exported && exported.default ? exported.default : exported;
175
176 if (typeof plugin === 'function') {
177 return plugin;
178 }
179
180 if (grapesjs.plugins && typeof grapesjs.plugins.get === 'function') {
181 return grapesjs.plugins.get('gjs-blocks-basic');
182 }
183
184 return undefined;
185 }
186
187 /**
188 * Report an asset-loading or start-up failure in the page.
189 *
190 * Only dependency names and the localized message are shown; no paths,
191 * versions or server detail are exposed.
192 *
193 * @param {string} message Localized explanation.
194 * @param {Array} missing Names of the dependencies that did not load.
195 * @param {Object} options Set fatal to true when the canvas is unusable.
196 */
197 function showLoadFailure(message, missing, options) {
198 const settings = options || {};
199 const $panel = $('#metasync-editor-load-failure');
200
201 console.error('MetaSync HTML Editor: ' + message +
202 (missing.length ? ' (' + missing.join(', ') + ')' : ''));
203
204 if (!$panel.length) {
205 return;
206 }
207
208 $panel.find('.metasync-load-failure-title').text(metasyncEditor.i18n.load_failed_title);
209 $panel.find('.metasync-load-failure-message').text(message);
210
211 const $detail = $panel.find('.metasync-load-failure-detail');
212 if (missing.length) {
213 $detail.text(
214 (metasyncEditor.i18n.load_failed_detail || '%s').replace('%s', missing.join(', '))
215 ).show();
216 } else {
217 $detail.hide();
218 }
219
220 $panel.find('.metasync-load-failure-reload')
221 .text(metasyncEditor.i18n.reload)
222 .off('click')
223 .on('click', function(e) {
224 e.preventDefault();
225 window.location.reload();
226 });
227
228 $panel.toggleClass('metasync-load-failure-fatal', !!settings.fatal);
229 // Clear `hidden` rather than calling .show(), which would set an inline
230 // display and break the panel's flex centering.
231 $panel.removeAttr('hidden');
232
233 if (settings.fatal) {
234 $('.metasync-save-button')
235 .prop('disabled', true)
236 .attr('title', metasyncEditor.i18n.save_disabled);
237 } else {
238 // Non-fatal: let the notice be dismissed and keep editing.
239 $panel.find('.metasync-load-failure-dismiss')
240 .text(metasyncEditor.i18n.dismiss)
241 .off('click')
242 .on('click', function(e) {
243 e.preventDefault();
244 $panel.hide();
245 })
246 .show();
247 }
248
249 updateStatus('error');
250 }
251
252 /**
253 * Sidebar panels, in switcher order.
254 *
255 * Each entry maps a switcher button to the sidebar panel it reveals, the
256 * command that activates it, and the localized-label key for its title.
257 *
258 * @type {Array<Object>}
259 */
260 const SIDEBAR_PANELS = [
261 { id: 'styles', command: 'show-styles', icon: 'dashicons-art', labelKey: 'panel_styles', fallback: 'Styles' },
262 { id: 'traits', command: 'show-traits', icon: 'dashicons-admin-generic', labelKey: 'panel_settings', fallback: 'Settings' },
263 { id: 'layers', command: 'show-layers', icon: 'dashicons-menu', labelKey: 'panel_layers', fallback: 'Layers' },
264 { id: 'blocks', command: 'show-blocks', icon: 'dashicons-grid-view', labelKey: 'panel_blocks', fallback: 'Blocks' }
265 ];
266
267 /**
268 * Resolve a localized string with an English fallback.
269 *
270 * @param {string} key Property under metasyncEditor.i18n.
271 * @param {string} fallback Used when the payload predates the key.
272 * @return {string}
273 */
274 function t(key, fallback) {
275 return (metasyncEditor.i18n && metasyncEditor.i18n[key]) || fallback;
276 }
277
278 /**
279 * Show a transient action notice in the page's live region.
280 *
281 * alert() blocks the UI thread, cannot be styled or dismissed by
282 * keyboard, and is unreachable to screen-reader users mid-flow; the
283 * notice element is announced automatically instead.
284 *
285 * @param {string} message Localized message to display.
286 */
287 let noticeTimer = null;
288 function showNotice(message) {
289 const $notice = $('#metasync-editor-notice');
290
291 if (!$notice.length) {
292 window.alert(message);
293 return;
294 }
295
296 $notice.text(message).removeAttr('hidden');
297
298 if (noticeTimer) {
299 clearTimeout(noticeTimer);
300 }
301 noticeTimer = setTimeout(function() {
302 $notice.attr('hidden', true);
303 }, 6000);
304 }
305
306 /**
307 * Render the sidebar's panel switcher buttons.
308 *
309 * The buttons are created in the sidebar itself rather than as GrapesJS
310 * panel buttons, so the editor re-rendering its panels cannot detach them.
311 */
312 function buildPanelSwitcher() {
313 const $switcher = $('#metasync-editor-panel-switcher');
314
315 if (!$switcher.length) {
316 return;
317 }
318
319 $switcher.empty();
320
321 SIDEBAR_PANELS.forEach(function(panel) {
322 const label = t(panel.labelKey, panel.fallback);
323 const $button = $('<button/>', {
324 type: 'button',
325 'class': 'metasync-panel-switch',
326 'data-metasync-panel-target': panel.id,
327 'aria-pressed': 'false',
328 title: label
329 });
330
331 $button.append($('<span/>', { 'class': 'dashicons ' + panel.icon, 'aria-hidden': 'true' }));
332 $button.append($('<span/>', { 'class': 'metasync-panel-switch-label', text: label }));
333 $button.on('click', function() {
334 editor.runCommand(panel.command);
335 });
336
337 $switcher.append($button);
338 });
339
340 showPanel('styles');
341 }
342
343 /**
344 * Reveal one sidebar panel and mark its switcher button active.
345 *
346 * @param {string} panelId Identifier from SIDEBAR_PANELS.
347 */
348 function showPanel(panelId) {
349 SIDEBAR_PANELS.forEach(function(panel) {
350 const isActive = panel.id === panelId;
351 const target = document.getElementById('metasync-panel-' + panel.id);
352
353 if (target) {
354 target.hidden = !isActive;
355 }
356
357 $('.metasync-panel-switch[data-metasync-panel-target="' + panel.id + '"]')
358 .toggleClass('is-active', isActive)
359 .attr('aria-pressed', isActive ? 'true' : 'false');
360 });
361 }
362
363 /**
364 * Initialize GrapesJS editor
365 */
366 function initializeEditor() {
367 const htmlContent = $('#metasync-html-content').val();
368 const blocksPlugin = resolveBlocksPlugin();
369
370 editor = grapesjs.init({
371 container: '#metasync-gjs-editor',
372 fromElement: false,
373 height: 'calc(100vh - 112px)',
374 width: 'auto',
375 storageManager: false, // Disable built-in storage
376
377 // Plugins. Passed by reference so the editor does not depend on the
378 // library registering itself under a legacy global name.
379 plugins: blocksPlugin ? [blocksPlugin] : [],
380 pluginsOpts: {},
381
382 // Enable double-click to edit text
383 allowScripts: 0,
384 showOffsets: 1,
385 noticeOnUnload: 0,
386
387 // Make text components editable
388 richTextEditor: {
389 actions: ['bold', 'italic', 'underline', 'strikethrough', 'link']
390 },
391
392 // Upload images through the plugin's AJAX endpoint. Without this
393 // the asset manager has no upload URL and its upload button does
394 // nothing at all — no request, no asset, no error.
395 assetManager: {
396 upload: metasyncEditor.ajax_url,
397 uploadName: 'file',
398 // Values must stay flat strings: the uploader FormData-appends
399 // each param verbatim, so a nested object would serialize as
400 // the literal "[object Object]".
401 params: {
402 action: 'metasync_upload_image',
403 nonce: metasyncEditor.nonce,
404 post_id: metasyncEditor.post_id
405 },
406 multiUpload: false,
407 // The endpoint can fail two ways: a WordPress envelope that
408 // reports the error with HTTP 200, and a non-200 response
409 // (expired nonce answers "-1" with 403, a fatal answers HTML
410 // with 500). Both must reject here, or the asset manager
411 // would add the error payload as a broken, imageless asset.
412 customFetch: function (url, options) {
413 return fetch(url, options)
414 .then(function (response) {
415 if (!response.ok) {
416 return response.text().then(function (text) {
417 var body = null;
418 try {
419 body = JSON.parse(text);
420 } catch (error) {
421 body = null;
422 }
423 return Promise.reject(body || { data: { message: 'HTTP ' + response.status } });
424 });
425 }
426 return response.text();
427 })
428 .then(function (text) {
429 var body;
430 try {
431 body = JSON.parse(text);
432 } catch (error) {
433 return text;
434 }
435 if (body && body.success === false) {
436 return Promise.reject(body);
437 }
438 return text;
439 });
440 }
441 },
442
443 // Canvas settings
444 canvas: {
445 styles: [],
446 scripts: []
447 },
448
449 // Block Manager
450 blockManager: {
451 appendTo: '#metasync-editor-blocks',
452 blocks: [
453 {
454 id: 'section',
455 label: '<div class="gjs-block-label">Section</div>',
456 content: '<section class="section"><h2>Section</h2><p>Add your content here</p></section>',
457 category: 'Basic',
458 attributes: { class: 'gjs-block-section' }
459 },
460 {
461 id: 'text',
462 label: '<div class="gjs-block-label">Text</div>',
463 content: '<div data-gjs-type="text">Insert your text here</div>',
464 category: 'Basic'
465 },
466 {
467 id: 'image',
468 label: '<div class="gjs-block-label">Image</div>',
469 content: { type: 'image' },
470 category: 'Basic',
471 activate: true
472 },
473 {
474 id: 'button',
475 label: '<div class="gjs-block-label">Button</div>',
476 content: '<a class="button">Button</a>',
477 category: 'Basic'
478 },
479 {
480 id: 'divider',
481 label: '<div class="gjs-block-label">Divider</div>',
482 content: '<hr/>',
483 category: 'Basic'
484 }
485 ]
486 },
487
488 // Style Manager
489 styleManager: {
490 appendTo: '#metasync-editor-styles',
491 sectors: [
492 {
493 name: 'Colors',
494 open: true,
495 properties: [
496 {
497 name: 'Text Color',
498 property: 'color',
499 type: 'color',
500 defaults: '#000000',
501 list: [
502 { value: '#000000', name: 'Black' },
503 { value: '#ffffff', name: 'White' },
504 { value: '#e53e3e', name: 'Red' },
505 { value: '#dd6b20', name: 'Orange' },
506 { value: '#d69e2e', name: 'Yellow' },
507 { value: '#38a169', name: 'Green' },
508 { value: '#3182ce', name: 'Blue' },
509 { value: '#805ad5', name: 'Purple' },
510 { value: '#d53f8c', name: 'Pink' },
511 { value: '#718096', name: 'Gray' }
512 ]
513 },
514 {
515 name: 'Background Color',
516 property: 'background-color',
517 type: 'color',
518 defaults: 'transparent',
519 list: [
520 { value: 'transparent', name: 'Transparent' },
521 { value: '#ffffff', name: 'White' },
522 { value: '#f7fafc', name: 'Gray 50' },
523 { value: '#edf2f7', name: 'Gray 100' },
524 { value: '#e2e8f0', name: 'Gray 200' },
525 { value: '#cbd5e0', name: 'Gray 300' },
526 { value: '#000000', name: 'Black' },
527 { value: '#e53e3e', name: 'Red' },
528 { value: '#dd6b20', name: 'Orange' },
529 { value: '#d69e2e', name: 'Yellow' },
530 { value: '#38a169', name: 'Green' },
531 { value: '#3182ce', name: 'Blue' },
532 { value: '#805ad5', name: 'Purple' }
533 ]
534 },
535 {
536 name: 'Border Color',
537 property: 'border-color',
538 type: 'color',
539 defaults: '#e2e8f0'
540 },
541 {
542 name: 'Gradient Background',
543 property: 'background',
544 type: 'stack',
545 layerLabel: function (layer, {values}) {
546 const type = values.type || '';
547 return type.charAt(0).toUpperCase() + type.slice(1);
548 },
549 list: [
550 { value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', name: 'Purple Bliss' },
551 { value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', name: 'Pink Passion' },
552 { value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', name: 'Blue Sky' },
553 { value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)', name: 'Green Beach' },
554 { value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)', name: 'Sunset' },
555 { value: 'linear-gradient(135deg, #30cfd0 0%, #330867 100%)', name: 'Deep Ocean' },
556 { value: 'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)', name: 'Pastel Dream' },
557 { value: 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%)', name: 'Pink Lady' },
558 { value: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)', name: 'Peach' },
559 { value: 'linear-gradient(135deg, #ff6e7f 0%, #bfe9ff 100%)', name: 'Cool Sunset' }
560 ]
561 },
562 {
563 name: 'Opacity',
564 property: 'opacity',
565 type: 'slider',
566 defaults: 1,
567 step: 0.01,
568 max: 1,
569 min: 0
570 }
571 ]
572 },
573 {
574 name: 'General',
575 open: false,
576 properties: [
577 'display',
578 'position',
579 'top',
580 'right',
581 'left',
582 'bottom'
583 ]
584 },
585 {
586 name: 'Dimensions',
587 open: false,
588 properties: [
589 'width',
590 'height',
591 'max-width',
592 'min-width',
593 'max-height',
594 'min-height',
595 'margin',
596 'padding'
597 ]
598 },
599 {
600 name: 'Typography',
601 open: false,
602 properties: [
603 {
604 name: 'Font Family',
605 property: 'font-family',
606 type: 'select',
607 defaults: 'Arial, sans-serif',
608 list: [
609 { value: 'Arial, sans-serif', name: 'Arial' },
610 { value: 'Georgia, serif', name: 'Georgia' },
611 { value: 'Impact, sans-serif', name: 'Impact' },
612 { value: 'Tahoma, sans-serif', name: 'Tahoma' },
613 { value: '"Times New Roman", serif', name: 'Times New Roman' },
614 { value: 'Verdana, sans-serif', name: 'Verdana' },
615 { value: '"Courier New", monospace', name: 'Courier New' },
616 { value: '"Lucida Console", monospace', name: 'Lucida Console' },
617 { value: '"Trebuchet MS", sans-serif', name: 'Trebuchet MS' },
618 { value: '"Helvetica Neue", Helvetica, Arial, sans-serif', name: 'Helvetica' }
619 ]
620 },
621 'font-size',
622 'font-weight',
623 'letter-spacing',
624 'line-height',
625 'text-align',
626 'text-decoration',
627 {
628 name: 'Text Shadow',
629 property: 'text-shadow',
630 type: 'stack',
631 layerLabel: function (layer, {values}) {
632 return 'Shadow';
633 },
634 list: [
635 { value: '2px 2px 4px rgba(0,0,0,0.3)', name: 'Soft Shadow' },
636 { value: '0 0 10px rgba(0,0,0,0.5)', name: 'Glow' },
637 { value: '3px 3px 0px rgba(0,0,0,1)', name: 'Hard Shadow' },
638 { value: '0 1px 0 #ccc, 0 2px 0 #c9c9c9, 0 3px 0 #bbb', name: '3D Text' }
639 ]
640 }
641 ]
642 },
643 {
644 name: 'Decorations',
645 open: false,
646 properties: [
647 {
648 name: 'Border Radius',
649 property: 'border-radius',
650 type: 'composite',
651 defaults: '0',
652 properties: [
653 { name: 'Top Left', property: 'border-top-left-radius', type: 'integer', units: ['px', '%'], defaults: '0' },
654 { name: 'Top Right', property: 'border-top-right-radius', type: 'integer', units: ['px', '%'], defaults: '0' },
655 { name: 'Bottom Right', property: 'border-bottom-right-radius', type: 'integer', units: ['px', '%'], defaults: '0' },
656 { name: 'Bottom Left', property: 'border-bottom-left-radius', type: 'integer', units: ['px', '%'], defaults: '0' }
657 ],
658 list: [
659 { value: '0', name: 'None' },
660 { value: '4px', name: 'Small' },
661 { value: '8px', name: 'Medium' },
662 { value: '16px', name: 'Large' },
663 { value: '50%', name: 'Circle' }
664 ]
665 },
666 'border',
667 {
668 name: 'Box Shadow',
669 property: 'box-shadow',
670 type: 'stack',
671 layerLabel: function (layer, {values}) {
672 return 'Shadow';
673 },
674 list: [
675 { value: 'none', name: 'None' },
676 { value: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', name: 'Small' },
677 { value: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', name: 'Medium' },
678 { value: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', name: 'Large' },
679 { value: '0 20px 25px -5px rgba(0, 0, 0, 0.1)', name: 'X-Large' },
680 { value: '0 0 0 3px rgba(66, 153, 225, 0.5)', name: 'Outline Blue' },
681 { value: '0 0 15px rgba(0, 0, 0, 0.2)', name: 'Glow' },
682 { value: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', name: 'Inner' }
683 ]
684 }
685 ]
686 },
687 {
688 name: 'Effects',
689 open: false,
690 properties: [
691 {
692 name: 'Transition',
693 property: 'transition',
694 type: 'stack',
695 list: [
696 { value: 'all 0.3s ease', name: 'Fast' },
697 { value: 'all 0.5s ease', name: 'Normal' },
698 { value: 'all 0.8s ease', name: 'Slow' },
699 { value: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', name: 'Smooth' }
700 ]
701 },
702 'perspective',
703 {
704 name: 'Transform',
705 property: 'transform',
706 type: 'composite',
707 properties: [
708 { name: 'Rotate', property: 'rotate', type: 'integer', units: ['deg'], defaults: '0' },
709 { name: 'Scale X', property: 'scale-x', type: 'number', defaults: '1' },
710 { name: 'Scale Y', property: 'scale-y', type: 'number', defaults: '1' }
711 ]
712 },
713 {
714 name: 'Filter',
715 property: 'filter',
716 type: 'composite',
717 properties: [
718 { name: 'Blur', property: 'blur', type: 'integer', units: ['px'], defaults: '0' },
719 { name: 'Brightness', property: 'brightness', type: 'integer', units: ['%'], defaults: '100' },
720 { name: 'Contrast', property: 'contrast', type: 'integer', units: ['%'], defaults: '100' },
721 { name: 'Grayscale', property: 'grayscale', type: 'integer', units: ['%'], defaults: '0' },
722 { name: 'Hue Rotate', property: 'hue-rotate', type: 'integer', units: ['deg'], defaults: '0' },
723 { name: 'Saturate', property: 'saturate', type: 'integer', units: ['%'], defaults: '100' }
724 ]
725 }
726 ]
727 },
728 {
729 name: 'Flex',
730 open: false,
731 properties: [
732 'flex-direction',
733 'flex-wrap',
734 'justify-content',
735 'align-items',
736 'align-content',
737 'order',
738 'flex-basis',
739 'flex-grow',
740 'flex-shrink',
741 'align-self'
742 ]
743 }
744 ]
745 },
746
747 // Layer Manager
748 layerManager: {
749 appendTo: '#metasync-editor-layers'
750 },
751
752 // Traits Manager - for editing element properties
753 traitManager: {
754 appendTo: '#metasync-editor-traits'
755 },
756
757 // Panels
758 panels: {
759 defaults: [
760 {
761 id: 'basic-actions',
762 el: '.gjs-pn-options',
763 buttons: [
764 {
765 id: 'visibility',
766 active: true,
767 className: 'btn-toggle-borders',
768 label: '<span class="dashicons dashicons-editor-table"></span>',
769 command: 'sw-visibility'
770 }
771 ]
772 },
773 {
774 id: 'panel-devices',
775 el: '.gjs-pn-devices',
776 buttons: [
777 {
778 id: 'device-desktop',
779 label: '<span class="dashicons dashicons-desktop"></span>',
780 command: 'set-device-desktop',
781 active: true,
782 togglable: false
783 },
784 {
785 id: 'device-tablet',
786 label: '<span class="dashicons dashicons-tablet"></span>',
787 command: 'set-device-tablet',
788 togglable: false
789 },
790 {
791 id: 'device-mobile',
792 label: '<span class="dashicons dashicons-smartphone"></span>',
793 command: 'set-device-mobile',
794 togglable: false
795 }
796 ]
797 }
798 ]
799 },
800
801 // Device Manager
802 deviceManager: {
803 devices: [
804 {
805 name: 'Desktop',
806 width: ''
807 },
808 {
809 name: 'Tablet',
810 width: '768px',
811 widthMedia: '992px'
812 },
813 {
814 name: 'Mobile',
815 width: '375px',
816 widthMedia: '480px'
817 }
818 ]
819 },
820
821 // Selector Manager
822 selectorManager: {
823 appendTo: '#metasync-editor-selectors'
824 }
825 });
826
827 // Each manager was handed its own `appendTo` target above, so GrapesJS
828 // renders the panels into the sidebar itself. Nothing is re-parented
829 // here, and no sector is registered a second time: the container is
830 // emptied on init, so anything appended to it by hand is discarded.
831
832 // Load HTML content
833 editor.setComponents(htmlContent);
834
835 // Extract and load styles
836 const styleMatch = htmlContent.match(/<style[^>]*>([\s\S]*?)<\/style>/gi);
837 if (styleMatch) {
838 let allStyles = '';
839 styleMatch.forEach(style => {
840 allStyles += style.replace(/<\/?style[^>]*>/gi, '');
841 });
842 editor.setStyle(allStyles);
843 }
844
845 // Enhance component types with better traits
846 editor.DomComponents.addType('text', {
847 model: {
848 defaults: {
849 traits: [
850 {
851 type: 'text',
852 name: 'content',
853 label: 'Text Content',
854 changeProp: 1
855 }
856 ]
857 }
858 }
859 });
860
861 editor.DomComponents.addType('link', {
862 model: {
863 defaults: {
864 traits: [
865 {
866 type: 'text',
867 name: 'href',
868 label: 'URL',
869 placeholder: 'https://example.com'
870 },
871 {
872 type: 'text',
873 name: 'title',
874 label: 'Title'
875 },
876 {
877 type: 'select',
878 name: 'target',
879 label: 'Target',
880 options: [
881 { value: '', name: 'Same Window' },
882 { value: '_blank', name: 'New Window' },
883 { value: '_parent', name: 'Parent Frame' },
884 { value: '_top', name: 'Top Frame' }
885 ]
886 }
887 ]
888 }
889 }
890 });
891
892 editor.DomComponents.addType('image', {
893 model: {
894 defaults: {
895 traits: [
896 {
897 type: 'text',
898 name: 'src',
899 label: 'Image URL',
900 placeholder: 'https://example.com/image.jpg'
901 },
902 {
903 type: 'text',
904 name: 'alt',
905 label: 'Alt Text',
906 placeholder: 'Image description'
907 },
908 {
909 type: 'text',
910 name: 'title',
911 label: 'Title'
912 }
913 ]
914 }
915 }
916 });
917
918 // Add device switching commands
919 editor.Commands.add('set-device-desktop', {
920 run: function(editor) {
921 editor.setDevice('Desktop');
922 }
923 });
924
925 editor.Commands.add('set-device-tablet', {
926 run: function(editor) {
927 editor.setDevice('Tablet');
928 }
929 });
930
931 editor.Commands.add('set-device-mobile', {
932 run: function(editor) {
933 editor.setDevice('Mobile');
934 }
935 });
936
937 // Panel-switching commands.
938 //
939 // Each manager renders into its own sidebar mount point, so switching
940 // is purely a matter of revealing the right panel. These used to also
941 // call render() on the manager and toggle GrapesJS panel classes,
942 // which fought with the editor's own rendering.
943 SIDEBAR_PANELS.forEach(function(panel) {
944 editor.Commands.add(panel.command, {
945 run: function() {
946 showPanel(panel.id);
947 }
948 });
949 });
950
951 // Build the sidebar's panel switcher, now that the commands its
952 // buttons run are registered.
953 //
954 // These were previously GrapesJS panel buttons that a timer tried to
955 // re-parent into the sidebar. The buttons are plain markup in the
956 // sidebar now, so they cannot be detached by the editor re-rendering
957 // its own panels.
958 buildPanelSwitcher();
959
960 // When an element is selected, automatically show the Styles panel
961 editor.on('component:selected', function(component) {
962 // Name the selected element in the sidebar. The indicator is part
963 // of the page template, so it does not need to be injected here.
964 const elementType = component.get('type') || 'div';
965 const elementName = component.getName() || elementType;
966 const indicator = document.getElementById('metasync-selected-element');
967
968 if (indicator) {
969 indicator.hidden = false;
970 }
971
972 $('#metasync-element-name').text(elementName.charAt(0).toUpperCase() + elementName.slice(1));
973
974 // Automatically switch to Styles panel when selecting an element
975 editor.runCommand('show-styles');
976 });
977
978 // Hide indicator when no element is selected
979 editor.on('component:deselected', function() {
980 const indicator = document.getElementById('metasync-selected-element');
981
982 if (indicator) {
983 indicator.hidden = true;
984 }
985 });
986
987 // Track changes
988 editor.on('change:changesCount', function() {
989 hasUnsavedChanges = true;
990 updateStatus('unsaved');
991 });
992
993 // Upload failures are rejected by the assetManager's customFetch and
994 // surface here. Tell the user instead of failing silently.
995 editor.on('asset:upload:error', function (error) {
996 var message = error && error.data && error.data.message;
997 showNotice(message || t('upload_failed', 'Image upload failed'));
998 });
999 }
1000
1001 /**
1002 * Initialize event handlers
1003 */
1004 function initializeEventHandlers() {
1005 // Save button
1006 $('.metasync-save-button').on('click', saveHTML);
1007
1008 // Preview button
1009 $('.metasync-preview-button').on('click', openPreview);
1010
1011 // Keyboard shortcuts
1012 $(document).on('keydown', function(e) {
1013 // Ctrl/Cmd + S to save
1014 if ((e.ctrlKey || e.metaKey) && e.key === 's') {
1015 e.preventDefault();
1016 saveHTML();
1017 }
1018 });
1019 }
1020
1021 /**
1022 * Save HTML via AJAX
1023 */
1024 function saveHTML() {
1025 // The save button is disabled after a fatal load, but the Ctrl/Cmd+S
1026 // binding still fires — there is no editor to read from then.
1027 if (!editor) {
1028 return;
1029 }
1030
1031 // Same reasoning for a page the canvas cannot round-trip: the button is
1032 // disabled, but the keyboard shortcut would otherwise still overwrite
1033 // the stored document with the canvas's reduced copy of it.
1034 if (metasyncEditor.blocked_reason) {
1035 showBlockedNotice();
1036 return;
1037 }
1038
1039 const $button = $('.metasync-save-button');
1040 const originalText = $button.text();
1041
1042 // Get HTML and CSS from editor
1043 const html = editor.getHtml();
1044 const css = editor.getCss();
1045
1046 // Combine HTML with CSS
1047 let fullHTML = html;
1048 if (css) {
1049 fullHTML = `<style>${css}</style>\n${html}`;
1050 }
1051
1052 // Update button state
1053 $button.prop('disabled', true).text(t('saving', 'Saving...'));
1054 updateStatus('saving');
1055
1056 // Send AJAX request
1057 $.ajax({
1058 url: metasyncEditor.ajax_url,
1059 type: 'POST',
1060 data: {
1061 action: 'metasync_save_html',
1062 nonce: metasyncEditor.nonce,
1063 post_id: metasyncEditor.post_id,
1064 html: fullHTML
1065 },
1066 success: function(response) {
1067 if (response.success) {
1068 hasUnsavedChanges = false;
1069 updateStatus('saved');
1070 $button.text(t('saved', 'Saved!'));
1071
1072 setTimeout(function() {
1073 $button.text(originalText);
1074 updateStatus('ready');
1075 }, 2000);
1076 } else {
1077 showNotice((response.data && response.data.message) || t('error', 'Error saving'));
1078 updateStatus('error');
1079 }
1080 },
1081 error: function(xhr) {
1082 // An expired nonce answers "-1" (or "0" logged-out) outside
1083 // the JSON envelope; anything else is a real failure. The
1084 // session-expired message warns that reloading will lose the
1085 // canvas, which the generic error message does not.
1086 var body = xhr && xhr.responseText;
1087 if (body === '-1' || body === '0') {
1088 showNotice(t('session_expired', 'Your session has expired. Copy your work before reloading the page.'));
1089 } else {
1090 var message = null;
1091 try {
1092 message = JSON.parse(body).data.message;
1093 } catch (parseError) {
1094 message = null;
1095 }
1096 showNotice(message || t('error', 'Error saving'));
1097 }
1098 updateStatus('error');
1099 },
1100 complete: function() {
1101 $button.prop('disabled', false);
1102 }
1103 });
1104 }
1105
1106 /**
1107 * Open preview in new tab
1108 */
1109 function openPreview() {
1110 if (hasUnsavedChanges) {
1111 if (!window.confirm(t('confirm_preview', 'You have unsaved changes. Preview will show the last saved version. Continue?'))) {
1112 return;
1113 }
1114 }
1115 window.open(metasyncEditor.preview_url, '_blank');
1116 }
1117
1118 /**
1119 * Update status indicator
1120 */
1121 function updateStatus(status) {
1122 const $indicator = $('.metasync-status-indicator');
1123 const $text = $('.metasync-status-text');
1124
1125 $indicator.removeClass('unsaved saving saved error ready');
1126 $indicator.addClass(status);
1127
1128 const statusText = {
1129 ready: t('ready', 'Ready'),
1130 unsaved: t('unsaved_changes', 'Unsaved changes'),
1131 saving: t('saving', 'Saving...'),
1132 saved: t('saved', 'Saved!'),
1133 error: t('error', 'Error saving')
1134 };
1135
1136 $text.text(statusText[status] || t('ready', 'Ready'));
1137 }
1138
1139 /**
1140 * Prevent accidental exit with unsaved changes
1141 */
1142 function preventAccidentalExit() {
1143 $(window).on('beforeunload', function(e) {
1144 // Nothing on a blocked page can be saved, so warning about unsaved
1145 // changes would only trap the user on a page they were told to
1146 // leave for the lossless editor.
1147 if (hasUnsavedChanges && !metasyncEditor.blocked_reason) {
1148 const message = metasyncEditor.i18n.confirm_exit;
1149 e.returnValue = message;
1150 return message;
1151 }
1152 });
1153 }
1154
1155 })(jQuery);
1156