| 1 |
<?php |
| 2 |
/** |
| 3 |
* MetaSync HTML Visual Editor |
| 4 |
* |
| 5 |
* Provides a visual editing interface for raw HTML pages with: |
| 6 |
* - Click to edit text |
| 7 |
* - Color picker for backgrounds/colors |
| 8 |
* - Image uploader |
| 9 |
* - Drag and drop reordering |
| 10 |
* - Live preview |
| 11 |
* |
| 12 |
* @package Metasync |
| 13 |
* @subpackage Metasync/admin |
| 14 |
* @since 2.0.0 |
| 15 |
*/ |
| 16 |
|
| 17 |
if (!defined('ABSPATH')) { |
| 18 |
exit; |
| 19 |
} |
| 20 |
|
| 21 |
class Metasync_HTML_Visual_Editor |
| 22 |
{ |
| 23 |
/** |
| 24 |
* Pinned version of the bundled GrapesJS core. |
| 25 |
* |
| 26 |
* @var string |
| 27 |
*/ |
| 28 |
const GRAPESJS_VERSION = '0.21.7'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Pinned version of the bundled grapesjs-blocks-basic plugin. |
| 32 |
* |
| 33 |
* Releases before 1.0.0 registered themselves by calling |
| 34 |
* grapesjs.plugins.add('gjs-blocks-basic'); 1.0.x only exposes a UMD |
| 35 |
* export, so the editor passes the plugin by reference rather than by |
| 36 |
* that legacy global name. |
| 37 |
* |
| 38 |
* @var string |
| 39 |
*/ |
| 40 |
const GRAPESJS_BLOCKS_BASIC_VERSION = '1.0.2'; |
| 41 |
|
| 42 |
/** |
| 43 |
* Plugin name |
| 44 |
* |
| 45 |
* @var string |
| 46 |
*/ |
| 47 |
private $plugin_name; |
| 48 |
|
| 49 |
/** |
| 50 |
* Plugin version |
| 51 |
* |
| 52 |
* @var string |
| 53 |
*/ |
| 54 |
private $version; |
| 55 |
|
| 56 |
/** |
| 57 |
* Initialize the class |
| 58 |
* |
| 59 |
* @param string $plugin_name Plugin name |
| 60 |
* @param string $version Plugin version |
| 61 |
*/ |
| 62 |
public function __construct($plugin_name, $version) |
| 63 |
{ |
| 64 |
$this->plugin_name = $plugin_name; |
| 65 |
$this->version = $version; |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Register hooks |
| 70 |
*/ |
| 71 |
public function init() |
| 72 |
{ |
| 73 |
// Add "Edit HTML" button to post row actions |
| 74 |
add_filter('post_row_actions', array($this, 'add_edit_html_button'), 10, 2); |
| 75 |
add_filter('page_row_actions', array($this, 'add_edit_html_button'), 10, 2); |
| 76 |
|
| 77 |
// Add admin menu page for the editor |
| 78 |
add_action('admin_menu', array($this, 'add_editor_page')); |
| 79 |
|
| 80 |
// Enqueue editor assets during the normal asset phase so stylesheets |
| 81 |
// land in <head> rather than being flushed late from the page body. |
| 82 |
add_action('admin_enqueue_scripts', array($this, 'maybe_enqueue_editor_assets')); |
| 83 |
|
| 84 |
// Register AJAX handlers |
| 85 |
add_action('wp_ajax_metasync_save_html', array($this, 'ajax_save_html')); |
| 86 |
add_action('wp_ajax_metasync_upload_image', array($this, 'ajax_upload_image')); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Admin page slug for the visual editor. |
| 91 |
* |
| 92 |
* @return string |
| 93 |
*/ |
| 94 |
private function get_editor_page_slug() |
| 95 |
{ |
| 96 |
return Metasync_Admin::$page_slug . '-html-editor'; |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Enqueue the editor assets when the current request is the editor page. |
| 101 |
* |
| 102 |
* Keyed on the request rather than on the hook suffix because the editor is |
| 103 |
* registered as a hidden submenu page, so its generated suffix is not |
| 104 |
* stable to match against. |
| 105 |
*/ |
| 106 |
public function maybe_enqueue_editor_assets() |
| 107 |
{ |
| 108 |
$page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; |
| 109 |
|
| 110 |
if ($page !== $this->get_editor_page_slug()) { |
| 111 |
return; |
| 112 |
} |
| 113 |
|
| 114 |
if (!current_user_can('edit_pages')) { |
| 115 |
return; |
| 116 |
} |
| 117 |
|
| 118 |
$this->enqueue_editor_assets(); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Add "Edit HTML" button to row actions |
| 123 |
* |
| 124 |
* @param array $actions Row actions |
| 125 |
* @param WP_Post $post Post object |
| 126 |
* @return array Modified actions |
| 127 |
*/ |
| 128 |
public function add_edit_html_button($actions, $post) |
| 129 |
{ |
| 130 |
// Check if this is a raw HTML page |
| 131 |
$has_raw_html = get_post_meta($post->ID, '_metasync_raw_html_enabled', true); |
| 132 |
|
| 133 |
if ($has_raw_html) { |
| 134 |
$edit_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-html-editor&post_id=' . $post->ID); |
| 135 |
$label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas'; |
| 136 |
|
| 137 |
$actions['edit_html'] = sprintf( |
| 138 |
'<a href="%s" title="%s">%s</a>', |
| 139 |
esc_url($edit_url), |
| 140 |
esc_attr(sprintf(__('Edit with %s Visual Editor', 'metasync'), $label)), |
| 141 |
__('Edit HTML', 'metasync') |
| 142 |
); |
| 143 |
} |
| 144 |
|
| 145 |
return $actions; |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Add editor admin page |
| 150 |
*/ |
| 151 |
public function add_editor_page() |
| 152 |
{ |
| 153 |
$label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas'; |
| 154 |
$page_title = sprintf(__('%s HTML Editor', 'metasync'), $label); |
| 155 |
|
| 156 |
add_submenu_page( |
| 157 |
'', // Hidden from menu |
| 158 |
$page_title, |
| 159 |
$page_title, |
| 160 |
'edit_pages', |
| 161 |
Metasync_Admin::$page_slug . '-html-editor', |
| 162 |
array($this, 'render_editor_page') |
| 163 |
); |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Render the visual editor page |
| 168 |
*/ |
| 169 |
public function render_editor_page() |
| 170 |
{ |
| 171 |
// Check permissions |
| 172 |
if (!current_user_can('edit_pages')) { |
| 173 |
wp_die(__('You do not have sufficient permissions to access this page.', 'metasync')); |
| 174 |
} |
| 175 |
|
| 176 |
// Get post ID |
| 177 |
$post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : 0; |
| 178 |
|
| 179 |
if (!$post_id) { |
| 180 |
wp_die(__('Invalid page ID.', 'metasync')); |
| 181 |
} |
| 182 |
|
| 183 |
if (!current_user_can('edit_post', $post_id)) { |
| 184 |
wp_die(__('You do not have sufficient permissions to access this page.', 'metasync')); |
| 185 |
} |
| 186 |
|
| 187 |
// Get post |
| 188 |
$post = get_post($post_id); |
| 189 |
|
| 190 |
if (!$post) { |
| 191 |
wp_die(__('Page not found.', 'metasync')); |
| 192 |
} |
| 193 |
|
| 194 |
// Check if raw HTML is enabled |
| 195 |
$has_raw_html = get_post_meta($post_id, '_metasync_raw_html_enabled', true); |
| 196 |
|
| 197 |
if (!$has_raw_html) { |
| 198 |
wp_die(__('This page is not a raw HTML page.', 'metasync')); |
| 199 |
} |
| 200 |
|
| 201 |
// Get HTML content |
| 202 |
$html_content = get_post_meta($post_id, '_metasync_raw_html_content', true); |
| 203 |
|
| 204 |
if (empty($html_content)) { |
| 205 |
$html_content = '<html><body><h1>Start editing...</h1></body></html>'; |
| 206 |
} |
| 207 |
|
| 208 |
// Get label for branding |
| 209 |
$label = Metasync::get_whitelabel_company_name() ?: 'SearchAtlas AI'; |
| 210 |
|
| 211 |
// Render editor UI. Assets are enqueued on admin_enqueue_scripts. |
| 212 |
include plugin_dir_path(__FILE__) . 'partials/metasync-html-editor-page.php'; |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Enqueue editor assets (GrapesJS + custom scripts) |
| 217 |
* |
| 218 |
* The editor runtime is served from the plugin's own bundled copies at |
| 219 |
* pinned versions. It used to be pulled from public CDNs, which made any |
| 220 |
* blocked, throttled or offline request render the editor as an empty |
| 221 |
* canvas with no explanation. |
| 222 |
*/ |
| 223 |
private function enqueue_editor_assets() |
| 224 |
{ |
| 225 |
$lib_url = plugin_dir_url(__FILE__) . 'lib/'; |
| 226 |
$lib_path = plugin_dir_path(__FILE__) . 'lib/'; |
| 227 |
|
| 228 |
// Bundled libraries, in load order. Anything missing from disk is |
| 229 |
// reported to the client so the editor can explain itself instead of |
| 230 |
// rendering blank. Handles are namespaced so a theme or plugin |
| 231 |
// registering a bare "grapesjs" handle cannot collide with ours. |
| 232 |
$libraries = array( |
| 233 |
'metasync-grapesjs' => array( |
| 234 |
'version' => self::GRAPESJS_VERSION, |
| 235 |
'style' => 'grapesjs/grapes.min.css', |
| 236 |
'script' => 'grapesjs/grapes.min.js', |
| 237 |
), |
| 238 |
'metasync-grapesjs-blocks-basic' => array( |
| 239 |
'version' => self::GRAPESJS_BLOCKS_BASIC_VERSION, |
| 240 |
'script' => 'grapesjs-blocks-basic/grapesjs-blocks-basic.min.js', |
| 241 |
'deps' => array('metasync-grapesjs'), |
| 242 |
), |
| 243 |
); |
| 244 |
|
| 245 |
$missing = array(); |
| 246 |
|
| 247 |
foreach ($libraries as $handle => $library) { |
| 248 |
if (file_exists($lib_path . $library['script'])) { |
| 249 |
wp_enqueue_script( |
| 250 |
$handle, |
| 251 |
$lib_url . $library['script'], |
| 252 |
isset($library['deps']) ? $library['deps'] : array(), |
| 253 |
$library['version'], |
| 254 |
true |
| 255 |
); |
| 256 |
} else { |
| 257 |
$missing[] = $handle; |
| 258 |
} |
| 259 |
|
| 260 |
if (!isset($library['style'])) { |
| 261 |
continue; |
| 262 |
} |
| 263 |
|
| 264 |
if (file_exists($lib_path . $library['style'])) { |
| 265 |
wp_enqueue_style( |
| 266 |
$handle, |
| 267 |
$lib_url . $library['style'], |
| 268 |
array(), |
| 269 |
$library['version'] |
| 270 |
); |
| 271 |
} else { |
| 272 |
$missing[] = $handle; |
| 273 |
} |
| 274 |
} |
| 275 |
|
| 276 |
$missing = array_values(array_unique($missing)); |
| 277 |
|
| 278 |
// The editor bundle depends on the bundled core only when that core |
| 279 |
// was actually enqueued. When a library file is absent from disk it is |
| 280 |
// never registered, and a dependency on an unregistered handle makes |
| 281 |
// WordPress suppress the editor bundle itself — including the localized |
| 282 |
// `missing` list, so the failure would go back to being a blank canvas |
| 283 |
// with no explanation. |
| 284 |
$editor_script_deps = array('jquery'); |
| 285 |
// The editor chrome and the sidebar's panel switcher label their |
| 286 |
// buttons with dashicons glyphs, so the stylesheet is a real |
| 287 |
// dependency rather than something to inherit from the admin page. |
| 288 |
$editor_style_deps = array('dashicons'); |
| 289 |
if (wp_script_is('metasync-grapesjs', 'registered')) { |
| 290 |
$editor_script_deps[] = 'metasync-grapesjs'; |
| 291 |
$editor_style_deps[] = 'metasync-grapesjs'; |
| 292 |
} |
| 293 |
|
| 294 |
wp_enqueue_script( |
| 295 |
'metasync-html-editor', |
| 296 |
plugins_url('js/metasync-html-editor.js', __FILE__), |
| 297 |
$editor_script_deps, |
| 298 |
$this->version, |
| 299 |
true |
| 300 |
); |
| 301 |
|
| 302 |
wp_enqueue_style( |
| 303 |
'metasync-html-editor', |
| 304 |
plugins_url('css/metasync-html-editor.css', __FILE__), |
| 305 |
$editor_style_deps, |
| 306 |
$this->version |
| 307 |
); |
| 308 |
|
| 309 |
// Localize script with data |
| 310 |
wp_localize_script('metasync-html-editor', 'metasyncEditor', array( |
| 311 |
'ajax_url' => admin_url('admin-ajax.php'), |
| 312 |
'nonce' => wp_create_nonce('metasync_html_editor'), |
| 313 |
'post_id' => isset($_GET['post_id']) ? intval($_GET['post_id']) : 0, |
| 314 |
'preview_url' => get_permalink(isset($_GET['post_id']) ? intval($_GET['post_id']) : 0), |
| 315 |
// Names of bundled libraries that are absent from disk, so the |
| 316 |
// client can name the failing dependency without exposing paths |
| 317 |
// or other sensitive detail. |
| 318 |
'missing' => $missing, |
| 319 |
'i18n' => array( |
| 320 |
'saving' => __('Saving...', 'metasync'), |
| 321 |
'saved' => __('Saved!', 'metasync'), |
| 322 |
'error' => __('Error saving', 'metasync'), |
| 323 |
'session_expired' => __('Your session has expired. Copy your work before reloading the page.', 'metasync'), |
| 324 |
'ready' => __('Ready', 'metasync'), |
| 325 |
'unsaved_changes' => __('Unsaved changes', 'metasync'), |
| 326 |
'confirm_exit' => __('You have unsaved changes. Are you sure you want to leave?', 'metasync'), |
| 327 |
'confirm_preview' => __('You have unsaved changes. Preview will show the last saved version. Continue?', 'metasync'), |
| 328 |
'panel_styles' => __('Styles', 'metasync'), |
| 329 |
'panel_settings' => __('Settings', 'metasync'), |
| 330 |
'panel_layers' => __('Layers', 'metasync'), |
| 331 |
'panel_blocks' => __('Blocks', 'metasync'), |
| 332 |
'load_failed_title' => __('The visual editor could not start', 'metasync'), |
| 333 |
'load_failed_core' => __('The visual editor library could not be loaded, so this page cannot be edited visually. Reload the page, and if the problem continues check whether a browser extension, proxy or content security policy is blocking plugin scripts.', 'metasync'), |
| 334 |
'load_failed_blocks' => __('The editor loaded, but its extra block library is unavailable, so the Blocks panel only offers the built-in blocks. Existing page content can still be edited and saved normally.', 'metasync'), |
| 335 |
'load_failed_init' => __('The visual editor failed to start while loading this page. Reload to try again; the saved page content has not been changed.', 'metasync'), |
| 336 |
'load_failed_detail' => __('Missing component: %s', 'metasync'), |
| 337 |
'reload' => __('Reload page', 'metasync'), |
| 338 |
'dismiss' => __('Dismiss', 'metasync'), |
| 339 |
'save_disabled' => __('Saving is disabled because the editor did not load', 'metasync'), |
| 340 |
'upload_failed' => __('Image upload failed', 'metasync'), |
| 341 |
) |
| 342 |
)); |
| 343 |
} |
| 344 |
|
| 345 |
/** |
| 346 |
* AJAX handler for saving HTML |
| 347 |
* |
| 348 |
* The payload is stored verbatim, matching the contract of every other |
| 349 |
* writer of this meta key: the Custom Pages metabox stores the raw value |
| 350 |
* for users who can edit the page, and the front-end renderer echoes it |
| 351 |
* as authored. Filtering here with kses would silently strip the very |
| 352 |
* elements raw HTML pages exist to carry (doctype, head assets, forms, |
| 353 |
* iframes, inline SVG), and re-filtering an already-filtered value is |
| 354 |
* what let entity-encoded markup re-materialize as live tags. |
| 355 |
*/ |
| 356 |
public function ajax_save_html() |
| 357 |
{ |
| 358 |
// Check nonce |
| 359 |
check_ajax_referer('metasync_html_editor', 'nonce'); |
| 360 |
|
| 361 |
// Check permissions |
| 362 |
if (!current_user_can('edit_pages')) { |
| 363 |
wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403); |
| 364 |
} |
| 365 |
|
| 366 |
// Get data |
| 367 |
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; |
| 368 |
|
| 369 |
if (!$post_id) { |
| 370 |
wp_send_json_error(array('message' => __('No page selected', 'metasync'))); |
| 371 |
} |
| 372 |
|
| 373 |
if (!current_user_can('edit_post', $post_id)) { |
| 374 |
wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403); |
| 375 |
} |
| 376 |
|
| 377 |
$html_content = isset($_POST['html']) ? wp_unslash($_POST['html']) : ''; |
| 378 |
|
| 379 |
if (empty($html_content)) { |
| 380 |
wp_send_json_error(array('message' => __('Nothing to save', 'metasync'))); |
| 381 |
} |
| 382 |
|
| 383 |
// Keep the value being replaced so a save that mangles the page can |
| 384 |
// be undone; postmeta is not revisioned, so this is the only undo. |
| 385 |
$previous = get_post_meta($post_id, '_metasync_raw_html_content', true); |
| 386 |
if ('' !== $previous) { |
| 387 |
update_post_meta($post_id, '_metasync_raw_html_content_previous', $previous); |
| 388 |
} |
| 389 |
|
| 390 |
// Save HTML content |
| 391 |
update_post_meta($post_id, '_metasync_raw_html_content', $html_content); |
| 392 |
|
| 393 |
// Update modified date |
| 394 |
wp_update_post(array( |
| 395 |
'ID' => $post_id, |
| 396 |
'post_modified' => current_time('mysql'), |
| 397 |
'post_modified_gmt' => current_time('mysql', 1) |
| 398 |
)); |
| 399 |
|
| 400 |
wp_send_json_success(array( |
| 401 |
'message' => __('Page saved successfully', 'metasync'), |
| 402 |
'preview_url' => get_permalink($post_id) |
| 403 |
)); |
| 404 |
} |
| 405 |
|
| 406 |
/** |
| 407 |
* AJAX handler for uploading images |
| 408 |
* |
| 409 |
* File validation is delegated entirely to the WordPress media pipeline |
| 410 |
* (wp_handle_upload + wp_check_filetype_and_ext via media_handle_upload), |
| 411 |
* the same chain the core media uploader uses; the capability gate |
| 412 |
* matches core's async-upload endpoint (upload_files). |
| 413 |
*/ |
| 414 |
public function ajax_upload_image() |
| 415 |
{ |
| 416 |
// Check nonce |
| 417 |
check_ajax_referer('metasync_html_editor', 'nonce'); |
| 418 |
|
| 419 |
// Check permissions |
| 420 |
if (!current_user_can('upload_files')) { |
| 421 |
wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403); |
| 422 |
} |
| 423 |
|
| 424 |
// Tie the upload to the page being edited, when the editor names one. |
| 425 |
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; |
| 426 |
if ($post_id && !current_user_can('edit_post', $post_id)) { |
| 427 |
wp_send_json_error(array('message' => __('Permission denied', 'metasync')), 403); |
| 428 |
} |
| 429 |
|
| 430 |
// Handle file upload |
| 431 |
if (!isset($_FILES['file'])) { |
| 432 |
wp_send_json_error(array('message' => __('No file uploaded', 'metasync'))); |
| 433 |
} |
| 434 |
|
| 435 |
require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| 436 |
require_once(ABSPATH . 'wp-admin/includes/file.php'); |
| 437 |
require_once(ABSPATH . 'wp-admin/includes/media.php'); |
| 438 |
|
| 439 |
$attachment_id = media_handle_upload('file', 0); |
| 440 |
|
| 441 |
if (is_wp_error($attachment_id)) { |
| 442 |
wp_send_json_error(array('message' => $attachment_id->get_error_message())); |
| 443 |
} |
| 444 |
|
| 445 |
$image_url = wp_get_attachment_url($attachment_id); |
| 446 |
|
| 447 |
// The editor's asset manager adds response.data to the asset list |
| 448 |
// directly, and an asset's source attribute is called `src`. The |
| 449 |
// attachment is exposed as `attachment_id` rather than `id` — `id` is |
| 450 |
// the Backbone collection's identity key, so reusing it would make |
| 451 |
// repeated uploads of the same attachment silently dedupe. |
| 452 |
wp_send_json_success(array( |
| 453 |
'src' => $image_url, |
| 454 |
'attachment_id' => $attachment_id |
| 455 |
)); |
| 456 |
} |
| 457 |
} |
| 458 |
|