| 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 |
|