PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / admin / js / metasync-html-editor.js

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

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