| 1 |
/*! |
| 2 |
* ZipWP MCP - Combined JavaScript |
| 3 |
* Version: 0.0.8 |
| 4 |
* Build: 2026-08-17 11:36:27 |
| 5 |
*/ |
| 6 |
|
| 7 |
/** |
| 8 |
* ZIPWP_LAYOUT — single source of truth for the assistant's panel layout and |
| 9 |
* appearance state: the localStorage keys, the layout/theme values + defaults, |
| 10 |
* the geometry constants, the context-aware default resolution, and the |
| 11 |
* "reset to defaults" routine. |
| 12 |
* |
| 13 |
* Loaded before wp-bridge-host / popover-drag (and the React app bundle) and |
| 14 |
* exposed as `window.ZIPWP_LAYOUT`, so the vanilla host scripts and the React |
| 15 |
* components all read ONE definition. No backward-compat shims — key strings |
| 16 |
* are the current ones on purpose so live prefs keep working. |
| 17 |
*/ |
| 18 |
( function() { |
| 19 |
'use strict'; |
| 20 |
|
| 21 |
const CONFIG = { |
| 22 |
// localStorage keys. |
| 23 |
keys: { |
| 24 |
layout: 'zipwp-panel-layout', |
| 25 |
open: 'zipwp-panel-open', |
| 26 |
popoverPosition: 'zipwp-popover-position', |
| 27 |
popoverSize: 'zipwp-popover-size', |
| 28 |
fabPosition: 'zip-ai-fab-position', |
| 29 |
theme: 'zip-ai-theme', |
| 30 |
}, |
| 31 |
// Panel layout values. |
| 32 |
layout: { sidebar: 'sidebar', popover: 'popover' }, |
| 33 |
// Appearance values. |
| 34 |
theme: { light: 'light', dark: 'dark', default: 'light' }, |
| 35 |
// Cross-tree broadcast fired by resetToDefaults(). |
| 36 |
events: { reset: 'zip-ai-reset-layout' }, |
| 37 |
// FAB launcher geometry. |
| 38 |
fab: { size: 56, margin: 8, dragThreshold: 4 }, |
| 39 |
// Popover card geometry. |
| 40 |
popover: { |
| 41 |
defaultWidth: 420, |
| 42 |
defaultHeight: 620, |
| 43 |
minWidth: 360, |
| 44 |
minHeight: 400, |
| 45 |
maxWidthMargin: 40, |
| 46 |
maxHeightMargin: 60, |
| 47 |
}, |
| 48 |
|
| 49 |
isValidLayout( value ) { |
| 50 |
return value === this.layout.sidebar || value === this.layout.popover; |
| 51 |
}, |
| 52 |
|
| 53 |
// Block-editor screens dock the sidebar beside the canvas; every other |
| 54 |
// admin screen defaults to the floating popover. Uses the |
| 55 |
// PHP-authoritative is_block_editor() flag with a DOM fallback. |
| 56 |
isBlockEditorContext() { |
| 57 |
const cfg = ( typeof window !== 'undefined' && window.zipwpIframeConfig ) || {}; |
| 58 |
if ( cfg.isBlockEditor ) { |
| 59 |
return true; |
| 60 |
} |
| 61 |
try { |
| 62 |
return document.body.classList.contains( 'block-editor-page' ) || |
| 63 |
!! document.querySelector( '.block-editor-block-list__layout' ); |
| 64 |
} catch ( e ) { |
| 65 |
return false; |
| 66 |
} |
| 67 |
}, |
| 68 |
|
| 69 |
// Explicit stored choice wins everywhere; otherwise the context default. |
| 70 |
resolveLayout() { |
| 71 |
let stored = null; |
| 72 |
try { |
| 73 |
stored = localStorage.getItem( this.keys.layout ); |
| 74 |
} catch ( e ) {} |
| 75 |
if ( this.isValidLayout( stored ) ) { |
| 76 |
return stored; |
| 77 |
} |
| 78 |
return this.isBlockEditorContext() ? this.layout.sidebar : this.layout.popover; |
| 79 |
}, |
| 80 |
|
| 81 |
// Wipe every persisted layout + appearance preference. Broadcasts the |
| 82 |
// reset event so components in other React trees (FAB launcher, header |
| 83 |
// theme) snap back live without a reload. Callers re-resolve the layout |
| 84 |
// and re-apply classes themselves. |
| 85 |
resetToDefaults() { |
| 86 |
try { |
| 87 |
localStorage.removeItem( this.keys.layout ); |
| 88 |
localStorage.removeItem( this.keys.popoverPosition ); |
| 89 |
localStorage.removeItem( this.keys.popoverSize ); |
| 90 |
localStorage.removeItem( this.keys.fabPosition ); |
| 91 |
localStorage.removeItem( this.keys.theme ); |
| 92 |
} catch ( e ) {} |
| 93 |
try { |
| 94 |
window.dispatchEvent( new CustomEvent( this.events.reset ) ); |
| 95 |
} catch ( e ) {} |
| 96 |
}, |
| 97 |
}; |
| 98 |
|
| 99 |
if ( typeof window !== 'undefined' ) { |
| 100 |
window.ZIPWP_LAYOUT = CONFIG; |
| 101 |
} |
| 102 |
}() ); |
| 103 |
|
| 104 |
|
| 105 |
/** |
| 106 |
* Tool Hooks Registry |
| 107 |
* |
| 108 |
* Minimal tool hooks system for registering and executing JavaScript handlers |
| 109 |
* for MCP tools. This is used by the WordPress bridge host to execute js_hook tools. |
| 110 |
*/ |
| 111 |
|
| 112 |
( function() { |
| 113 |
'use strict'; |
| 114 |
|
| 115 |
class ToolHooksRegistry { |
| 116 |
constructor() { |
| 117 |
this.hooks = new Map(); |
| 118 |
this.listeners = new Map(); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Register a JavaScript handler for a tool |
| 123 |
* |
| 124 |
* @param {string} toolName - The tool name (e.g., 'myplugin/my-action') |
| 125 |
* @param {Function} handler - The handler function |
| 126 |
* @param {Object} options - Additional options |
| 127 |
* @return {boolean} True on success |
| 128 |
*/ |
| 129 |
registerHandler( toolName, handler, options = {} ) { |
| 130 |
if ( ! toolName || typeof handler !== 'function' ) { |
| 131 |
return false; |
| 132 |
} |
| 133 |
|
| 134 |
const config = { |
| 135 |
handler, |
| 136 |
previewMode: options.previewMode || 'client', |
| 137 |
priority: options.priority || 10, |
| 138 |
...options, |
| 139 |
}; |
| 140 |
|
| 141 |
this.hooks.set( toolName, config ); |
| 142 |
|
| 143 |
return true; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Check if a tool has a registered handler |
| 148 |
* |
| 149 |
* @param {string} toolName - The tool name |
| 150 |
* @return {boolean} True if handler exists |
| 151 |
*/ |
| 152 |
hasHandler( toolName ) { |
| 153 |
return this.hooks.has( toolName ); |
| 154 |
} |
| 155 |
|
| 156 |
/** |
| 157 |
* Execute a tool using its registered JavaScript handler |
| 158 |
* |
| 159 |
* @param {string} toolName - The tool name |
| 160 |
* @param {Object} args - Tool arguments |
| 161 |
* @param {Object} context - Execution context |
| 162 |
* @return {Promise<Object>} Execution result |
| 163 |
*/ |
| 164 |
async executeToolHook( toolName, args, context = {} ) { |
| 165 |
const config = this.hooks.get( toolName ); |
| 166 |
|
| 167 |
if ( ! config ) { |
| 168 |
return { |
| 169 |
success: false, |
| 170 |
error: `No JavaScript handler registered for tool: ${ toolName }`, |
| 171 |
}; |
| 172 |
} |
| 173 |
|
| 174 |
try { |
| 175 |
// Emit pre-execution event |
| 176 |
this.emitEvent( 'beforeToolExecution', { toolName, args, context } ); |
| 177 |
|
| 178 |
// Execute the handler |
| 179 |
const result = await config.handler( args, context ); |
| 180 |
|
| 181 |
// Ensure result has proper structure — pass through operation fields. |
| 182 |
// The outer `message` reflects actual status so callers don't read |
| 183 |
// "executed successfully" when the inner operation failed. |
| 184 |
const operationSucceeded = result?.success !== false; |
| 185 |
const defaultMessage = operationSucceeded |
| 186 |
? `Tool ${ toolName } executed successfully` |
| 187 |
: ( result?.error || `Tool ${ toolName } failed` ); |
| 188 |
const formattedResult = { |
| 189 |
success: operationSucceeded, |
| 190 |
data: result?.data || result, |
| 191 |
message: result?.message || defaultMessage, |
| 192 |
user_summary: result?.user_summary || null, |
| 193 |
operation: result?.operation || null, |
| 194 |
verification: result?.verification || result?.data?.verification || null, |
| 195 |
error: result?.error || null, |
| 196 |
toolName, |
| 197 |
executionMode: 'js_hook', |
| 198 |
}; |
| 199 |
|
| 200 |
// Emit post-execution event |
| 201 |
this.emitEvent( 'afterToolExecution', { toolName, args, context, result: formattedResult } ); |
| 202 |
|
| 203 |
return formattedResult; |
| 204 |
} catch ( error ) { |
| 205 |
const errorResult = { |
| 206 |
success: false, |
| 207 |
error: error.message || 'Tool execution failed', |
| 208 |
toolName, |
| 209 |
executionMode: 'js_hook', |
| 210 |
}; |
| 211 |
|
| 212 |
// Emit error event |
| 213 |
this.emitEvent( 'toolExecutionError', { toolName, args, context, error } ); |
| 214 |
|
| 215 |
return errorResult; |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
/** |
| 220 |
* Emit an event to all registered listeners |
| 221 |
* |
| 222 |
* @param {string} eventName - Event name |
| 223 |
* @param {any} data - Event data |
| 224 |
*/ |
| 225 |
emitEvent( eventName, data ) { |
| 226 |
const callbacks = this.listeners.get( eventName ) || []; |
| 227 |
callbacks.forEach( ( callback ) => { |
| 228 |
try { |
| 229 |
callback( data ); |
| 230 |
} catch { |
| 231 |
// Event listener error - continue with other listeners |
| 232 |
} |
| 233 |
} ); |
| 234 |
} |
| 235 |
} |
| 236 |
|
| 237 |
// Create singleton instance |
| 238 |
const toolHooksRegistry = new ToolHooksRegistry(); |
| 239 |
|
| 240 |
// Make available globally |
| 241 |
window.zipwpMcp = window.zipwpMcp || {}; |
| 242 |
window.zipwpMcp.toolHooks = toolHooksRegistry; |
| 243 |
window.zipwpMcp.registerTool = function( toolName, handler, options = {} ) { |
| 244 |
return toolHooksRegistry.registerHandler( toolName, handler, options ); |
| 245 |
}; |
| 246 |
window.zipwpMcpHooks = toolHooksRegistry; |
| 247 |
}() ); |
| 248 |
|
| 249 |
|
| 250 |
/** |
| 251 |
* ZipWP MCP - Spectra Shared Utilities |
| 252 |
* |
| 253 |
* Shared utility functions used across Spectra tool handlers and context providers. |
| 254 |
* |
| 255 |
* @package |
| 256 |
*/ |
| 257 |
|
| 258 |
( function() { |
| 259 |
'use strict'; |
| 260 |
|
| 261 |
/** |
| 262 |
* Text attributes to extract for lightweight serialization. |
| 263 |
*/ |
| 264 |
const TEXT_ATTRIBUTES = [ |
| 265 |
'content', 'text', 'title', 'heading', 'description', |
| 266 |
'label', 'placeholder', 'caption', 'citation', 'value', |
| 267 |
'buttonText', 'linkText', 'question', 'answer', |
| 268 |
]; |
| 269 |
|
| 270 |
/** |
| 271 |
* Extract only text attributes from block attributes. |
| 272 |
* Truncates long text to save tokens. |
| 273 |
* |
| 274 |
* @param {Object} attributes - Block attributes |
| 275 |
* @return {Object} Object with only text attributes |
| 276 |
*/ |
| 277 |
function extractTextAttributes( attributes ) { |
| 278 |
if ( ! attributes ) { |
| 279 |
return {}; |
| 280 |
} |
| 281 |
|
| 282 |
const texts = {}; |
| 283 |
for ( const attr of TEXT_ATTRIBUTES ) { |
| 284 |
if ( typeof attributes[ attr ] === 'string' && attributes[ attr ].trim() ) { |
| 285 |
const text = attributes[ attr ].trim(); |
| 286 |
texts[ attr ] = text.length > 200 ? text.substring( 0, 200 ) + '...' : text; |
| 287 |
} |
| 288 |
} |
| 289 |
return texts; |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Walk a block subtree DFS and return the first non-empty text attribute |
| 294 |
* encountered (the block itself first, then its descendants in document |
| 295 |
* order). Used to derive a user-recognisable label for a selected block |
| 296 |
* whose own attributes may be empty but whose children carry content — |
| 297 |
* e.g. a Spectra/Container that wraps the heading block of a hero. |
| 298 |
* |
| 299 |
* @param {Object} block |
| 300 |
* @return {string} Plain trimmed text, or '' when nothing found. |
| 301 |
*/ |
| 302 |
function findFirstText( block ) { |
| 303 |
if ( ! block ) { |
| 304 |
return ''; |
| 305 |
} |
| 306 |
const attrs = block.attributes || {}; |
| 307 |
for ( const key of TEXT_ATTRIBUTES ) { |
| 308 |
const v = attrs[ key ]; |
| 309 |
if ( typeof v === 'string' && v.trim() ) { |
| 310 |
return v.replace( /<[^>]*>/g, '' ).trim(); |
| 311 |
} |
| 312 |
} |
| 313 |
if ( Array.isArray( block.innerBlocks ) ) { |
| 314 |
for ( const child of block.innerBlocks ) { |
| 315 |
const nested = findFirstText( child ); |
| 316 |
if ( nested ) { |
| 317 |
return nested; |
| 318 |
} |
| 319 |
} |
| 320 |
} |
| 321 |
return ''; |
| 322 |
} |
| 323 |
|
| 324 |
/** |
| 325 |
* Serialize a block with only essential data (clientId, name, texts). |
| 326 |
* Lightweight version for AI operations - minimizes token usage. |
| 327 |
* |
| 328 |
* Also emits `primary_text`: a single user-recognisable label derived |
| 329 |
* from the block's own text attributes (or its subtree when the block |
| 330 |
* itself carries no text). Consumed by the React chat UI to make the |
| 331 |
* selected-block badge identifiable on pages with many same-type |
| 332 |
* containers. Capped at 120 chars. |
| 333 |
* |
| 334 |
* @param {Object} block - WordPress block object |
| 335 |
* @return {object|null} Lightweight serialized block or null |
| 336 |
*/ |
| 337 |
function serializeBlockLight( block ) { |
| 338 |
if ( ! block ) { |
| 339 |
return null; |
| 340 |
} |
| 341 |
|
| 342 |
const texts = extractTextAttributes( block.attributes ); |
| 343 |
const result = { |
| 344 |
clientId: block.clientId, |
| 345 |
name: block.name, |
| 346 |
texts, |
| 347 |
}; |
| 348 |
|
| 349 |
const primary = findFirstText( block ); |
| 350 |
if ( primary ) { |
| 351 |
result.primary_text = primary.length > 120 ? primary.substring( 0, 117 ) + '…' : primary; |
| 352 |
} |
| 353 |
|
| 354 |
// The HTML tag the user gave this block — Spectra content stores it in |
| 355 |
// `tagName` (h1/h2/p/span), a container in `htmlTag` (section/div/header). |
| 356 |
// Surfaced so the selection chip can show the actual tag instead of the |
| 357 |
// raw block-type slug (falls back to the block name when absent). |
| 358 |
const attrs = block.attributes || {}; |
| 359 |
const tag = typeof attrs.tagName === 'string' && attrs.tagName !== '' |
| 360 |
? attrs.tagName |
| 361 |
: ( typeof attrs.htmlTag === 'string' && attrs.htmlTag !== '' ? attrs.htmlTag : '' ); |
| 362 |
if ( tag ) { |
| 363 |
result.tag = tag; |
| 364 |
} |
| 365 |
|
| 366 |
if ( block.innerBlocks && block.innerBlocks.length > 0 ) { |
| 367 |
result.innerBlocks = block.innerBlocks |
| 368 |
.map( ( innerBlock ) => serializeBlockLight( innerBlock ) ) |
| 369 |
.filter( ( b ) => b !== null ); |
| 370 |
} |
| 371 |
|
| 372 |
return result; |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* Serialize a block for context/transmission. |
| 377 |
* Recursively includes innerBlocks and HTML representation. |
| 378 |
* |
| 379 |
* @param {Object} block - WordPress block object |
| 380 |
* @return {object|null} Serialized block or null if invalid |
| 381 |
*/ |
| 382 |
function serializeBlock( block ) { |
| 383 |
if ( ! block ) { |
| 384 |
return null; |
| 385 |
} |
| 386 |
|
| 387 |
// Use WordPress serializer to get HTML representation |
| 388 |
let blocksHtml = ''; |
| 389 |
if ( window.wp?.blocks?.serialize ) { |
| 390 |
try { |
| 391 |
blocksHtml = wp.blocks.serialize( [ block ] ); |
| 392 |
} catch ( e ) { |
| 393 |
console.warn( 'Failed to serialize block:', e ); // eslint-disable-line no-console -- intentional error surfacing |
| 394 |
} |
| 395 |
} |
| 396 |
|
| 397 |
return { |
| 398 |
clientId: block.clientId, |
| 399 |
name: block.name, |
| 400 |
blockName: block.name, // Also include as blockName for backend compatibility |
| 401 |
attributes: block.attributes, |
| 402 |
attrs: block.attributes, // Also include as attrs for backend compatibility |
| 403 |
innerBlocks: ( block.innerBlocks || [] ).map( ( innerBlock ) => serializeBlock( innerBlock ) ), |
| 404 |
blocks_html: blocksHtml, // Pre-serialized HTML for direct use |
| 405 |
}; |
| 406 |
} |
| 407 |
|
| 408 |
/** |
| 409 |
* Screenshot utilities using Screen Capture API |
| 410 |
* Captures exactly what's rendered on screen with persistent stream (one-time permission) |
| 411 |
*/ |
| 412 |
|
| 413 |
/** |
| 414 |
* Get block DOM element by clientId. |
| 415 |
* |
| 416 |
* WordPress 6.3+ renders the editor canvas inside an internal iframe |
| 417 |
* (`iframe[name="editor-canvas"]`), so a direct `document.querySelector` |
| 418 |
* on the top document misses every block. Fall through to each |
| 419 |
* same-origin iframe's document before returning null. |
| 420 |
* |
| 421 |
* @param {string} clientId - Block client ID |
| 422 |
* @return {HTMLElement|null} Block element or null |
| 423 |
*/ |
| 424 |
function getBlockElement( clientId ) { |
| 425 |
if ( ! clientId ) { |
| 426 |
return null; |
| 427 |
} |
| 428 |
const selector = '[data-block="' + clientId + '"]'; |
| 429 |
// Top document first (legacy / non-iframe editors). |
| 430 |
const direct = document.querySelector( selector ); |
| 431 |
if ( direct ) { |
| 432 |
return direct; |
| 433 |
} |
| 434 |
// Then every iframe we can reach without a cross-origin error. |
| 435 |
const iframes = document.querySelectorAll( 'iframe' ); |
| 436 |
for ( let i = 0; i < iframes.length; i++ ) { |
| 437 |
let doc = null; |
| 438 |
try { |
| 439 |
doc = iframes[ i ].contentDocument || ( iframes[ i ].contentWindow && iframes[ i ].contentWindow.document ); |
| 440 |
} catch ( err ) { |
| 441 |
doc = null; |
| 442 |
} |
| 443 |
if ( ! doc ) { |
| 444 |
continue; |
| 445 |
} |
| 446 |
const nested = doc.querySelector( selector ); |
| 447 |
if ( nested ) { |
| 448 |
return nested; |
| 449 |
} |
| 450 |
} |
| 451 |
return null; |
| 452 |
} |
| 453 |
|
| 454 |
/** |
| 455 |
* Inject the zip-ai-block-pulse keyframes + class into a document once. |
| 456 |
* Idempotent per-document. Must be called on the owner-document of the |
| 457 |
* element we'll animate — injecting into the top document does nothing |
| 458 |
* when the block lives inside Gutenberg's editor-canvas iframe. |
| 459 |
* |
| 460 |
* @param {Document} [doc] - Target document. Defaults to top document. |
| 461 |
*/ |
| 462 |
function ensurePulseStyles( doc ) { |
| 463 |
const target = doc || document; |
| 464 |
if ( ! target || ! target.head ) { |
| 465 |
return; |
| 466 |
} |
| 467 |
if ( target.getElementById( 'zip-ai-block-pulse-styles' ) ) { |
| 468 |
return; |
| 469 |
} |
| 470 |
const style = target.createElement( 'style' ); |
| 471 |
style.id = 'zip-ai-block-pulse-styles'; |
| 472 |
style.textContent = |
| 473 |
'@keyframes zip-ai-block-pulse {' + |
| 474 |
'0%, 100% { box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.9), 0 0 0 10px rgba(124, 58, 237, 0); }' + |
| 475 |
'50% { box-shadow: 0 0 0 3px rgba(124, 58, 237, 1), 0 0 0 14px rgba(124, 58, 237, 0.18); }' + |
| 476 |
'}' + |
| 477 |
'.zip-ai-block-pulse {' + |
| 478 |
'animation: zip-ai-block-pulse 0.9s ease-in-out 2;' + |
| 479 |
'border-radius: 4px;' + |
| 480 |
'outline: none !important;' + |
| 481 |
'}'; |
| 482 |
target.head.appendChild( style ); |
| 483 |
} |
| 484 |
|
| 485 |
/** |
| 486 |
* Scroll the block identified by clientId into view and play a brief |
| 487 |
* pulse highlight so the user can visually locate it. Used by the |
| 488 |
* selected-block badge in the chat UI. |
| 489 |
* |
| 490 |
* Silent no-op if the element isn't in the DOM (block may be in an |
| 491 |
* iframe we can't reach, or was just replaced — don't throw). |
| 492 |
* |
| 493 |
* @param {string} clientId |
| 494 |
* @param {{ durationMs?: number, scroll?: boolean }} [options] |
| 495 |
* @return {boolean} true when an element was highlighted. |
| 496 |
*/ |
| 497 |
function highlightBlock( clientId, options ) { |
| 498 |
const opts = options || {}; |
| 499 |
const el = getBlockElement( clientId ); |
| 500 |
if ( ! el ) { |
| 501 |
return false; |
| 502 |
} |
| 503 |
// Inject the animation CSS into the element's owner document so it |
| 504 |
// works inside Gutenberg's editor-canvas iframe as well as on the |
| 505 |
// top document. |
| 506 |
ensurePulseStyles( el.ownerDocument ); |
| 507 |
if ( opts.scroll !== false ) { |
| 508 |
try { |
| 509 |
el.scrollIntoView( { behavior: 'smooth', block: 'center' } ); |
| 510 |
} catch ( err ) { |
| 511 |
// Older browsers without smooth scroll — fall back to default. |
| 512 |
try { |
| 513 |
el.scrollIntoView(); |
| 514 |
} catch ( e ) { /* ignore */ } |
| 515 |
} |
| 516 |
} |
| 517 |
el.classList.remove( 'zip-ai-block-pulse' ); |
| 518 |
// Force reflow so re-adding the class restarts the animation. |
| 519 |
// eslint-disable-next-line no-unused-expressions |
| 520 |
void el.offsetWidth; |
| 521 |
el.classList.add( 'zip-ai-block-pulse' ); |
| 522 |
const duration = typeof opts.durationMs === 'number' ? opts.durationMs : 1800; |
| 523 |
window.setTimeout( () => { |
| 524 |
el.classList.remove( 'zip-ai-block-pulse' ); |
| 525 |
}, duration ); |
| 526 |
return true; |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* Persistent screen capture stream (reused to avoid repeated permission prompts) |
| 531 |
*/ |
| 532 |
let screenCaptureStream = null; |
| 533 |
let screenCaptureVideo = null; |
| 534 |
let screenCapturePermissionDenied = false; // Remember if user cancelled/denied |
| 535 |
|
| 536 |
/** |
| 537 |
* Initialize or get existing screen capture stream |
| 538 |
* Permission is requested only once per session |
| 539 |
* If user cancels, won't ask again until page reload |
| 540 |
* @return {Promise<{stream: MediaStream, video: HTMLVideoElement}|null>} |
| 541 |
*/ |
| 542 |
async function getScreenCaptureStream() { |
| 543 |
// Don't ask again if user already denied/cancelled |
| 544 |
if ( screenCapturePermissionDenied ) { |
| 545 |
return null; |
| 546 |
} |
| 547 |
|
| 548 |
// Check if existing stream is still active |
| 549 |
if ( screenCaptureStream && screenCaptureVideo ) { |
| 550 |
const tracks = screenCaptureStream.getVideoTracks(); |
| 551 |
if ( tracks.length > 0 && tracks[ 0 ].readyState === 'live' ) { |
| 552 |
return { stream: screenCaptureStream, video: screenCaptureVideo }; |
| 553 |
} |
| 554 |
// Stream ended, clean up |
| 555 |
screenCaptureStream = null; |
| 556 |
screenCaptureVideo = null; |
| 557 |
} |
| 558 |
|
| 559 |
try { |
| 560 |
// Request new stream (prompts user once) |
| 561 |
screenCaptureStream = await navigator.mediaDevices.getDisplayMedia( { |
| 562 |
video: { |
| 563 |
displaySurface: 'browser', |
| 564 |
preferCurrentTab: true, |
| 565 |
}, |
| 566 |
preferCurrentTab: true, |
| 567 |
} ); |
| 568 |
|
| 569 |
// Create persistent video element |
| 570 |
screenCaptureVideo = document.createElement( 'video' ); |
| 571 |
screenCaptureVideo.srcObject = screenCaptureStream; |
| 572 |
screenCaptureVideo.muted = true; |
| 573 |
await screenCaptureVideo.play(); |
| 574 |
|
| 575 |
// Listen for stream end (user stops sharing) |
| 576 |
screenCaptureStream.getVideoTracks()[ 0 ].addEventListener( 'ended', () => { |
| 577 |
screenCaptureStream = null; |
| 578 |
screenCaptureVideo = null; |
| 579 |
} ); |
| 580 |
|
| 581 |
return { stream: screenCaptureStream, video: screenCaptureVideo }; |
| 582 |
} catch ( error ) { |
| 583 |
console.warn( 'Screen capture permission denied:', error.message ); // eslint-disable-line no-console -- intentional error surfacing |
| 584 |
// Remember that user denied/cancelled - don't ask again until page reload |
| 585 |
screenCapturePermissionDenied = true; |
| 586 |
return null; |
| 587 |
} |
| 588 |
} |
| 589 |
|
| 590 |
/** |
| 591 |
* Capture screenshot of an element using the persistent screen capture stream |
| 592 |
* @param {HTMLElement} element - Element to capture |
| 593 |
* @param clientId |
| 594 |
* @param {Object} options - Capture options |
| 595 |
* @return {Promise<{base64: string, width: number, height: number}|null>} |
| 596 |
*/ |
| 597 |
async function captureBlockScreenshot( clientId, options = {} ) { |
| 598 |
const { maxWidth = 1200, quality = 0.8 } = options; |
| 599 |
|
| 600 |
const element = getBlockElement( clientId ); |
| 601 |
if ( ! element ) { |
| 602 |
console.warn( 'Block element not found for clientId:', clientId ); // eslint-disable-line no-console -- intentional error surfacing |
| 603 |
return null; |
| 604 |
} |
| 605 |
|
| 606 |
// Scroll element into view |
| 607 |
element.scrollIntoView( { behavior: 'instant', block: 'center' } ); |
| 608 |
await new Promise( ( resolve ) => setTimeout( resolve, 150 ) ); |
| 609 |
|
| 610 |
// Get or initialize screen capture stream |
| 611 |
const capture = await getScreenCaptureStream(); |
| 612 |
if ( ! capture ) { |
| 613 |
return null; |
| 614 |
} |
| 615 |
|
| 616 |
const { video } = capture; |
| 617 |
|
| 618 |
try { |
| 619 |
// Get element position relative to viewport |
| 620 |
const rect = element.getBoundingClientRect(); |
| 621 |
|
| 622 |
// Draw current video frame to canvas |
| 623 |
const fullCanvas = document.createElement( 'canvas' ); |
| 624 |
fullCanvas.width = video.videoWidth; |
| 625 |
fullCanvas.height = video.videoHeight; |
| 626 |
const fullCtx = fullCanvas.getContext( '2d' ); |
| 627 |
fullCtx.drawImage( video, 0, 0 ); |
| 628 |
|
| 629 |
// Calculate scale factor (screen capture may be at different DPI) |
| 630 |
const scaleX = video.videoWidth / window.innerWidth; |
| 631 |
const scaleY = video.videoHeight / window.innerHeight; |
| 632 |
|
| 633 |
// Crop to element bounds |
| 634 |
const cropX = Math.max( 0, rect.left * scaleX ); |
| 635 |
const cropY = Math.max( 0, rect.top * scaleY ); |
| 636 |
const cropWidth = Math.min( rect.width * scaleX, fullCanvas.width - cropX ); |
| 637 |
const cropHeight = Math.min( rect.height * scaleY, fullCanvas.height - cropY ); |
| 638 |
|
| 639 |
// Create cropped canvas |
| 640 |
const croppedCanvas = document.createElement( 'canvas' ); |
| 641 |
croppedCanvas.width = cropWidth; |
| 642 |
croppedCanvas.height = cropHeight; |
| 643 |
const croppedCtx = croppedCanvas.getContext( '2d' ); |
| 644 |
croppedCtx.drawImage( fullCanvas, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight ); |
| 645 |
|
| 646 |
// Optimize if needed |
| 647 |
let finalCanvas = croppedCanvas; |
| 648 |
if ( croppedCanvas.width > maxWidth ) { |
| 649 |
finalCanvas = document.createElement( 'canvas' ); |
| 650 |
const ratio = maxWidth / croppedCanvas.width; |
| 651 |
finalCanvas.width = maxWidth; |
| 652 |
finalCanvas.height = croppedCanvas.height * ratio; |
| 653 |
const ctx = finalCanvas.getContext( '2d' ); |
| 654 |
ctx.drawImage( croppedCanvas, 0, 0, finalCanvas.width, finalCanvas.height ); |
| 655 |
} |
| 656 |
|
| 657 |
return { |
| 658 |
base64: finalCanvas.toDataURL( 'image/jpeg', quality ), |
| 659 |
width: finalCanvas.width, |
| 660 |
height: finalCanvas.height, |
| 661 |
}; |
| 662 |
} catch ( error ) { |
| 663 |
console.error( 'Screenshot capture failed:', error ); // eslint-disable-line no-console -- intentional error surfacing |
| 664 |
return null; |
| 665 |
} |
| 666 |
} |
| 667 |
|
| 668 |
// Export utilities to global scope for use by tool handlers and context providers |
| 669 |
window.zipwpMcpSpectraUtils = { |
| 670 |
serializeBlock, |
| 671 |
serializeBlockLight, |
| 672 |
extractTextAttributes, |
| 673 |
// Screenshot utilities |
| 674 |
getScreenCaptureStream, |
| 675 |
getBlockElement, |
| 676 |
highlightBlock, |
| 677 |
captureBlockScreenshot, |
| 678 |
}; |
| 679 |
}() ); |
| 680 |
|
| 681 |
|
| 682 |
/** |
| 683 |
* ZipWP MCP — Vibe Editing v2: shared editor utilities. |
| 684 |
* |
| 685 |
* The ONE source of truth for helpers the editor handlers (get-context, |
| 686 |
* apply-change, get/set-scripts, get/set-styles) need identically — so they can |
| 687 |
* never drift: |
| 688 |
* - currentPostId: the live post id from the editor store, with the same |
| 689 |
* null-guard. apply-change uses it for the two-tab post_id guard; |
| 690 |
* get-context returns it in the response. |
| 691 |
* - editorSelect / editorDispatch: the null-guarded `core/editor` store |
| 692 |
* select/dispatch accessors. styles read the editing session through these |
| 693 |
* (getEditedPostAttribute / editPost). |
| 694 |
* - currentMeta: the live post meta object from the session (or {}), the |
| 695 |
* read side of the session-scoped styles write. |
| 696 |
* - blockEditorSelect / blockEditorDispatch / rootClientId: the null-guarded |
| 697 |
* `core/block-editor` store accessors + the page root container's clientId. |
| 698 |
* get/set-scripts read/patch a block's `spectraCustomJS` attribute through |
| 699 |
* these (getBlockAttributes / updateBlockAttributes). |
| 700 |
* - bannedVisualAttrs / isBannedVisualAttr: the 8 GBS-banned per-block visual |
| 701 |
* styling props (style, *Color(Hover), boxShadow(Hover), styleAttributes) — |
| 702 |
* styling lives in `className` ONLY (the Spectra GBS JIT grammar). apply-change |
| 703 |
* strips them from an incoming attrs write; get-context omits them from the |
| 704 |
* authorable attr-keys it advertises. ONE list so the two can never drift |
| 705 |
* (mirrors Laravel's StrictAttrValidator). The 8 props are a sanctioned |
| 706 |
* constant, not an allowlist — every OTHER attr is decided by the registry. |
| 707 |
* - isJsCapable: is `spectraCustomJS` actually a registered attribute on this |
| 708 |
* block type? spectra-blocks-pro only registers it on spectra/spectra-pro |
| 709 |
* blocks + core/image + core/heading (global-styles/helpers.js |
| 710 |
* SUPPORTED_BLOCKS) — writing it anywhere else paints the session but is |
| 711 |
* dropped on Save. Read LIVE off `wp.blocks.getBlockType`, not a mirrored |
| 712 |
* name list, so it can't drift from whatever the plugin actually registered. |
| 713 |
* |
| 714 |
* Dual-mode: attaches to window.zipwpEditorShared in the browser; CommonJS |
| 715 |
* export for jest. |
| 716 |
* |
| 717 |
* Load order: enqueued before the editor handlers in source mode |
| 718 |
* (react-manager.php) and concatenated ahead of handler.js in the production |
| 719 |
* grunt bundle (matches the `tools/**\/*-utils.js` glob). The handlers also |
| 720 |
* resolve it lazily at call time, so a missing global degrades consistently |
| 721 |
* (post id -> null = the two-tab guard no-ops, exactly as a null |
| 722 |
* getCurrentPostId() would). |
| 723 |
* |
| 724 |
* @package |
| 725 |
*/ |
| 726 |
( function () { |
| 727 |
'use strict'; |
| 728 |
|
| 729 |
function editorSelect() { |
| 730 |
return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select ) |
| 731 |
? window.wp.data.select( 'core/editor' ) |
| 732 |
: null; |
| 733 |
} |
| 734 |
|
| 735 |
function editorDispatch() { |
| 736 |
return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch ) |
| 737 |
? window.wp.data.dispatch( 'core/editor' ) |
| 738 |
: null; |
| 739 |
} |
| 740 |
|
| 741 |
function currentPostId() { |
| 742 |
const editor = editorSelect(); |
| 743 |
return editor && typeof editor.getCurrentPostId === 'function' |
| 744 |
? editor.getCurrentPostId() |
| 745 |
: null; |
| 746 |
} |
| 747 |
|
| 748 |
// The live post meta object from the editing session (or {} when absent) — |
| 749 |
// the read side of the session-scoped scripts/styles write. |
| 750 |
function currentMeta( sel ) { |
| 751 |
const meta = sel && sel.getEditedPostAttribute ? sel.getEditedPostAttribute( 'meta' ) : null; |
| 752 |
return meta && typeof meta === 'object' ? meta : {}; |
| 753 |
} |
| 754 |
|
| 755 |
// The null-guarded `core/block-editor` store — the read/write side of a |
| 756 |
// block's attributes (get/set-scripts operate on `spectraCustomJS`, an attr). |
| 757 |
// Edits through it are inherently session-scoped: in-memory now, persisted on |
| 758 |
// Save, discarded with the session (same model apply-change uses). |
| 759 |
function blockEditorSelect() { |
| 760 |
return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select ) |
| 761 |
? window.wp.data.select( 'core/block-editor' ) |
| 762 |
: null; |
| 763 |
} |
| 764 |
function blockEditorDispatch() { |
| 765 |
return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch ) |
| 766 |
? window.wp.data.dispatch( 'core/block-editor' ) |
| 767 |
: null; |
| 768 |
} |
| 769 |
|
| 770 |
// The page root container's clientId — the first top-level block — the |
| 771 |
// default owner for page-wide JS when no clientId is passed. null when the |
| 772 |
// tree is empty or the store is absent. |
| 773 |
function rootClientId( sel ) { |
| 774 |
const order = sel && sel.getBlockOrder ? sel.getBlockOrder() : null; |
| 775 |
return Array.isArray( order ) && order.length > 0 ? order[ 0 ] : null; |
| 776 |
} |
| 777 |
|
| 778 |
// The 13 GBS-banned per-block visual styling props. Per-block styling lives in |
| 779 |
// `className` ONLY (the Spectra GBS JIT grammar); these are never authorable |
| 780 |
// attrs even when a block's registry declares them. A sanctioned constant — |
| 781 |
// every OTHER attribute's validity is decided by the registry (getBlockType). |
| 782 |
// |
| 783 |
// HARDCODED MIRROR of config/spectra-contract.json `banned_attrs.keys` in the |
| 784 |
// api-scs-credits-system repo (the SSOT the brain zod + Laravel validator read). |
| 785 |
// Kept in lockstep by hand for now; a generated artifact + cross-repo CI pin is |
| 786 |
// the sanctioned fix (drift here is otherwise silent — apply_change attrs bypass |
| 787 |
// Laravel's validator, so this client strip is the editor's only write-point guard |
| 788 |
// besides the brain denylist). Per the contract's `notes.update_ops`, the full |
| 789 |
// 13-key ban applies name-agnostically on the clientId-targeted apply_change path |
| 790 |
// (no block name at validation time); insert/replace BlockSpecs carry a `name` and |
| 791 |
// are scope-aware (srfm/* exempt) — that scope-awareness is a separate follow-up. |
| 792 |
const bannedVisualAttrs = [ |
| 793 |
'style', 'styleAttributes', |
| 794 |
'backgroundColor', 'backgroundColorHover', |
| 795 |
'boxShadow', 'boxShadowHover', |
| 796 |
'textColor', 'textColorHover', |
| 797 |
'numberColor', 'numberColorHover', |
| 798 |
'borderHover', 'iconColorHover', |
| 799 |
'backgroundGradientHover', |
| 800 |
]; |
| 801 |
function isBannedVisualAttr( key ) { |
| 802 |
return bannedVisualAttrs.indexOf( key ) !== -1; |
| 803 |
} |
| 804 |
|
| 805 |
// True when this block type actually persists `spectraCustomJS` (the |
| 806 |
// registry, not a mirrored name list — see the file header). |
| 807 |
function isJsCapable( blockName ) { |
| 808 |
const blocks = ( typeof window !== 'undefined' && window.wp && window.wp.blocks ) ? window.wp.blocks : null; |
| 809 |
if ( ! blocks || typeof blocks.getBlockType !== 'function' || typeof blockName !== 'string' ) { |
| 810 |
return false; |
| 811 |
} |
| 812 |
const blockType = blocks.getBlockType( blockName ); |
| 813 |
return !! ( blockType && blockType.attributes && |
| 814 |
Object.prototype.hasOwnProperty.call( blockType.attributes, 'spectraCustomJS' ) ); |
| 815 |
} |
| 816 |
|
| 817 |
// ── Dependents (reverse edges) ────────────────────────────────────────── |
| 818 |
// The editor resolves OWNERSHIP (which layer owns a property) but never |
| 819 |
// DEPENDENTS ("who else relies on the resource I'm about to change"). These |
| 820 |
// pure helpers compute a mutating write's blast radius from the LIVE tree — |
| 821 |
// no persistent index — so a destructive/shared write can surface its impact |
| 822 |
// as a structured signal (act-first: the model informs the user, it doesn't |
| 823 |
// silently break things). SSOT here so every handler reads dependents identically. |
| 824 |
|
| 825 |
// Every id a block's JS references — `getElementById('x')` or a `#x` inside a |
| 826 |
// querySelector. Shared by set-scripts' WRITE-time dead-#id gate AND |
| 827 |
// apply-change's DELETE-time orphan-JS surface, so the two never drift on how |
| 828 |
// JS is parsed. |
| 829 |
function referencedIds( code ) { |
| 830 |
const ids = []; |
| 831 |
if ( typeof code !== 'string' || code === '' ) { |
| 832 |
return ids; |
| 833 |
} |
| 834 |
let m; |
| 835 |
const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g; |
| 836 |
while ( ( m = reGid.exec( code ) ) !== null ) { |
| 837 |
ids.push( m[ 1 ] ); |
| 838 |
} |
| 839 |
const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g; |
| 840 |
while ( ( m = reQs.exec( code ) ) !== null ) { |
| 841 |
// eslint-disable-next-line no-var |
| 842 |
var idm, |
| 843 |
reId = /#([A-Za-z][\w-]*)/g; |
| 844 |
while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) { |
| 845 |
ids.push( idm[ 1 ] ); |
| 846 |
} |
| 847 |
} |
| 848 |
return ids; |
| 849 |
} |
| 850 |
|
| 851 |
// Every clientId in the live tree (top-level + descendants). |
| 852 |
function allClientIds( sel ) { |
| 853 |
return ( sel && sel.getClientIdsWithDescendants ) ? sel.getClientIdsWithDescendants() : []; |
| 854 |
} |
| 855 |
|
| 856 |
// Blocks (excluding `excludeIds`) whose `className` carries `gsToken` — the |
| 857 |
// reverse `class -> blocks` edge. Answers "editing this gs- class body also |
| 858 |
// restyles N other sections" BEFORE the write. |
| 859 |
function classDependents( sel, gsToken, excludeIds ) { |
| 860 |
const out = []; |
| 861 |
if ( ! sel || ! gsToken || ! sel.getBlockAttributes ) { |
| 862 |
return out; |
| 863 |
} |
| 864 |
const skip = Object.create( null ); |
| 865 |
( excludeIds || [] ).forEach( function ( id ) { |
| 866 |
skip[ id ] = true; |
| 867 |
} ); |
| 868 |
allClientIds( sel ).forEach( function ( cid ) { |
| 869 |
if ( skip[ cid ] ) { |
| 870 |
return; |
| 871 |
} |
| 872 |
const a = sel.getBlockAttributes( cid ); |
| 873 |
const cn = ( a && typeof a.className === 'string' ) ? a.className : ''; |
| 874 |
if ( cn.split( /\s+/ ).indexOf( gsToken ) !== -1 ) { |
| 875 |
out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null } ); |
| 876 |
} |
| 877 |
} ); |
| 878 |
return out; |
| 879 |
} |
| 880 |
|
| 881 |
// Blocks (excluding `excludeIds`) whose `spectraCustomJS` references any of |
| 882 |
// `anchors` (by `#id` / getElementById) — the reverse `anchor -> scripts` |
| 883 |
// edge. Answers "deleting this block orphans the JS on N others" BEFORE the |
| 884 |
// delete (their getElementById(...) would return null and THROW, killing the |
| 885 |
// whole per-block IIFE — not a clean no-op). |
| 886 |
function anchorDependents( sel, anchors, excludeIds ) { |
| 887 |
const out = []; |
| 888 |
if ( ! sel || ! anchors || ! anchors.length || ! sel.getBlockAttributes ) { |
| 889 |
return out; |
| 890 |
} |
| 891 |
const want = Object.create( null ); |
| 892 |
anchors.forEach( function ( x ) { |
| 893 |
if ( x ) { |
| 894 |
want[ x ] = true; |
| 895 |
} |
| 896 |
} ); |
| 897 |
const skip = Object.create( null ); |
| 898 |
( excludeIds || [] ).forEach( function ( id ) { |
| 899 |
skip[ id ] = true; |
| 900 |
} ); |
| 901 |
allClientIds( sel ).forEach( function ( cid ) { |
| 902 |
if ( skip[ cid ] ) { |
| 903 |
return; |
| 904 |
} |
| 905 |
const a = sel.getBlockAttributes( cid ); |
| 906 |
const js = ( a && typeof a.spectraCustomJS === 'string' ) ? a.spectraCustomJS : ''; |
| 907 |
if ( ! js ) { |
| 908 |
return; |
| 909 |
} |
| 910 |
const hits = referencedIds( js ).filter( function ( id ) { |
| 911 |
return want[ id ]; |
| 912 |
} ); |
| 913 |
if ( hits.length ) { |
| 914 |
out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null, refs: hits } ); |
| 915 |
} |
| 916 |
} ); |
| 917 |
return out; |
| 918 |
} |
| 919 |
|
| 920 |
// ── GBS page-store persist (SSOT) ────────────────────────────────────────── |
| 921 |
// ONE read→merge→write→render→inject path over the per-page GBS store, shared |
| 922 |
// by editor/set-styles (the styling tool) AND editor/apply-change (which uses |
| 923 |
// it to persist a generated section's semantic-token class bodies — the JIT |
| 924 |
// cannot synthesize gs-* class bodies, so without a store write the section |
| 925 |
// renders unstyled). `apiFetch` is injected by the caller (its own wp.apiFetch |
| 926 |
// wrapper); NS is the GBS route root. |
| 927 |
const GBS_NS = '/spectra-blocks/v1/global-styles'; |
| 928 |
|
| 929 |
// Deep-merge one incoming schema-v1 payload onto an existing one, bucket by |
| 930 |
// bucket (null value = delete). Never full-replaces — importer chrome / other |
| 931 |
// sections' classes in untouched buckets survive. |
| 932 |
function mergePayload( existing, incoming ) { |
| 933 |
const out = Object.assign( {}, existing || {} ); |
| 934 |
out.v = '1'; |
| 935 |
Object.keys( incoming || {} ).forEach( function ( bucket ) { |
| 936 |
if ( bucket === 'v' ) { |
| 937 |
return; |
| 938 |
} |
| 939 |
const inc = incoming[ bucket ]; |
| 940 |
if ( inc === null ) { |
| 941 |
delete out[ bucket ]; |
| 942 |
return; |
| 943 |
} |
| 944 |
if ( Array.isArray( inc ) ) { |
| 945 |
out[ bucket ] = inc.slice(); |
| 946 |
return; |
| 947 |
} |
| 948 |
if ( typeof inc !== 'object' ) { |
| 949 |
return; |
| 950 |
} |
| 951 |
const base = ( out[ bucket ] && typeof out[ bucket ] === 'object' && ! Array.isArray( out[ bucket ] ) ) |
| 952 |
? Object.assign( {}, out[ bucket ] ) |
| 953 |
: {}; |
| 954 |
Object.keys( inc ).forEach( function ( key ) { |
| 955 |
if ( inc[ key ] === null ) { |
| 956 |
delete base[ key ]; |
| 957 |
} else { |
| 958 |
base[ key ] = inc[ key ]; |
| 959 |
} |
| 960 |
} ); |
| 961 |
out[ bucket ] = base; |
| 962 |
} ); |
| 963 |
return out; |
| 964 |
} |
| 965 |
|
| 966 |
// The block-editor canvas runs in an iframe; styles must be injected THERE. |
| 967 |
function canvasDoc() { |
| 968 |
const ifr = document.querySelector( 'iframe[name="editor-canvas"]' ); |
| 969 |
return ifr && ifr.contentDocument ? ifr.contentDocument : document; |
| 970 |
} |
| 971 |
function injectCss( elementId, css ) { |
| 972 |
const doc = canvasDoc(); |
| 973 |
let el = doc.getElementById( elementId ); |
| 974 |
if ( ! el ) { |
| 975 |
el = doc.createElement( 'style' ); |
| 976 |
el.id = elementId; |
| 977 |
( doc.head || doc.documentElement ).appendChild( el ); |
| 978 |
} |
| 979 |
el.textContent = css || ''; |
| 980 |
} |
| 981 |
|
| 982 |
// Tag a REST failure with which of the three GBS steps it came from so a |
| 983 |
// caller can label the outcome granularly. `step` is 'read' | 'write' | |
| 984 |
// 'render' — a render failure means the data WAS saved, only the live paint |
| 985 |
// failed (recoverable by reload), which is a different remediation than a |
| 986 |
// read/write failure where nothing persisted. The message is preserved. |
| 987 |
function taggedGbsError( step, e ) { |
| 988 |
const err = e instanceof Error ? e : new Error( String( e && e.message ? e.message : e ) ); |
| 989 |
err.gbsStep = step; |
| 990 |
return err; |
| 991 |
} |
| 992 |
|
| 993 |
// Read-modify-write the per-page GBS store through the SSOT /save route (the |
| 994 |
// same endpoint the importer uses) then render + inject the merged CSS so it |
| 995 |
// paints live. Returns the merged payload; throws on any REST failure (tagged |
| 996 |
// with `gbsStep`) so the caller can decide whether to surface or swallow it |
| 997 |
// and, if surfacing, which step failed. |
| 998 |
async function persistPageGbs( apiFetch, incoming, postId ) { |
| 999 |
let existing; |
| 1000 |
try { |
| 1001 |
existing = await apiFetch( { path: GBS_NS + '/save?scope=page&post_id=' + postId } ) |
| 1002 |
.then( function ( g ) { |
| 1003 |
return ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {}; |
| 1004 |
} ); |
| 1005 |
} catch ( e ) { |
| 1006 |
throw taggedGbsError( 'read', e ); |
| 1007 |
} |
| 1008 |
const merged = mergePayload( existing, incoming ); |
| 1009 |
try { |
| 1010 |
await apiFetch( { |
| 1011 |
path: GBS_NS + '/save', |
| 1012 |
method: 'POST', |
| 1013 |
data: { scope: 'page', post_id: postId, payload: merged, replace: true }, |
| 1014 |
} ); |
| 1015 |
} catch ( e ) { |
| 1016 |
throw taggedGbsError( 'write', e ); |
| 1017 |
} |
| 1018 |
try { |
| 1019 |
const r = await apiFetch( { |
| 1020 |
path: GBS_NS + '/render', |
| 1021 |
method: 'POST', |
| 1022 |
data: { payload: merged, post_id: postId, scope: 'page' }, |
| 1023 |
} ); |
| 1024 |
injectCss( 'spectra-gen-custom-css-' + postId + '-inline-css', r && r.css ); |
| 1025 |
} catch ( e ) { |
| 1026 |
throw taggedGbsError( 'render', e ); |
| 1027 |
} |
| 1028 |
return merged; |
| 1029 |
} |
| 1030 |
|
| 1031 |
// ── Deferred section-GBS persistence (persist on SAVE, never before) ───────── |
| 1032 |
// A generate_section insert carries custom `gs-` class BODIES — semantic-token |
| 1033 |
// CSS (var(--primary)/…) the JIT can't compile as utilities, so it can't ride |
| 1034 |
// the block className the way per-block styling now does. We must NOT write it |
| 1035 |
// to the DB immediately: that persists before the user Saves, breaking the |
| 1036 |
// "nothing hits the DB until Save" contract (and orphaning CSS if they discard). |
| 1037 |
// Instead: RENDER + inject it for a live PREVIEW now (a pure compile — NO /save, |
| 1038 |
// no DB write), ACCUMULATE the payload, and FLUSH it to the GBS store only when |
| 1039 |
// the editor completes a real Save. Never Saved → never persisted. |
| 1040 |
|
| 1041 |
// Preview-only: compile the payload via the SSOT renderer and inject it into the |
| 1042 |
// canvas. NO /save — this never touches the DB. (The saved-meta render replaces |
| 1043 |
// this element on the next reload.) |
| 1044 |
async function previewSectionGbs( apiFetch, incoming, postId ) { |
| 1045 |
const r = await apiFetch( { |
| 1046 |
path: GBS_NS + '/render', |
| 1047 |
method: 'POST', |
| 1048 |
data: { payload: incoming, post_id: postId, scope: 'page' }, |
| 1049 |
} ); |
| 1050 |
injectCss( 'zipwp-gbs-pending-section-' + postId, r && r.css ); |
| 1051 |
} |
| 1052 |
|
| 1053 |
// Remove the pending-section preview <style> for a post — after Save flushes, |
| 1054 |
// the persisted-meta render (spectra-gen-custom-css-<postId>) paints the |
| 1055 |
// section, so the preview element is stale duplicate CSS. |
| 1056 |
function removePendingSectionStyle( postId ) { |
| 1057 |
const el = canvasDoc().getElementById( 'zipwp-gbs-pending-section-' + postId ); |
| 1058 |
if ( el && el.parentNode ) { |
| 1059 |
el.parentNode.removeChild( el ); |
| 1060 |
} |
| 1061 |
} |
| 1062 |
|
| 1063 |
// Build a deferred saver. Deps are injectable (apiFetch, editorStore, subscribe, |
| 1064 |
// persist, preview, merge) so the accumulate → flush-on-save logic is testable |
| 1065 |
// without a live wp.data. `queue(incoming, postId)` merges the payload into the |
| 1066 |
// pending set (per post), paints the preview, and arms a ONE-TIME subscription |
| 1067 |
// that flushes every pending payload through the real persist the first time a |
| 1068 |
// NON-autosave Save completes successfully. |
| 1069 |
function createSectionGbsSaver( deps ) { |
| 1070 |
const apiFetch = deps.apiFetch; |
| 1071 |
const editorStore = deps.editorStore; // () => core/editor select, or the select object |
| 1072 |
const subscribe = deps.subscribe; // (cb) => unsubscribe |
| 1073 |
const persist = deps.persist || persistPageGbs; |
| 1074 |
const preview = deps.preview || previewSectionGbs; |
| 1075 |
const removePreview = deps.removePreview || removePendingSectionStyle; |
| 1076 |
const merge = deps.merge || mergePayload; |
| 1077 |
const pending = {}; // postId -> merged payload |
| 1078 |
let armed = false; |
| 1079 |
let wasSaving = false; |
| 1080 |
|
| 1081 |
function resolveStore() { |
| 1082 |
return typeof editorStore === 'function' ? editorStore() : editorStore; |
| 1083 |
} |
| 1084 |
function flush() { |
| 1085 |
Object.keys( pending ).forEach( function ( postId ) { |
| 1086 |
const payload = pending[ postId ]; |
| 1087 |
// Optimistically clear so a second Save with nothing new is a no-op… |
| 1088 |
delete pending[ postId ]; |
| 1089 |
Promise.resolve( persist( apiFetch, payload, Number( postId ) ) ).then( function () { |
| 1090 |
// …persisted: the saved-meta render now owns the paint, so drop |
| 1091 |
// the stale preview <style> — but ONLY if nothing was re-queued |
| 1092 |
// for this post while the persist was in flight. A queue() during |
| 1093 |
// the flush re-creates the shared per-post preview element for a |
| 1094 |
// section that hasn't persisted yet; removing it here would leave |
| 1095 |
// that section unstyled until the next Save. |
| 1096 |
if ( ! pending[ postId ] ) { |
| 1097 |
removePreview( Number( postId ) ); |
| 1098 |
} |
| 1099 |
} ).catch( function ( e ) { |
| 1100 |
// A 'render'-tagged rejection means the /save DB write SUCCEEDED |
| 1101 |
// and only the live paint failed (recoverable by reload). The |
| 1102 |
// payload IS persisted, so treat it like success: drop the stale |
| 1103 |
// preview and do NOT re-queue (re-queuing would rewrite an |
| 1104 |
// already-saved body and, via the merge below, could clobber a |
| 1105 |
// newer queued edit). |
| 1106 |
if ( e && e.gbsStep === 'render' ) { |
| 1107 |
if ( ! pending[ postId ] ) { |
| 1108 |
removePreview( Number( postId ) ); |
| 1109 |
} |
| 1110 |
return; |
| 1111 |
} |
| 1112 |
// A genuine not-persisted failure (read/write): re-queue so the |
| 1113 |
// NEXT Save retries — a transient REST error must not permanently |
| 1114 |
// lose the section's styling. `payload` is the OLDER failed batch, |
| 1115 |
// so merge it UNDER anything queued since (2nd arg wins per key) |
| 1116 |
// to keep a newer regenerate from being clobbered by the stale body. |
| 1117 |
pending[ postId ] = merge( payload, pending[ postId ] || {} ); |
| 1118 |
// eslint-disable-next-line no-console -- developer signal; retried on the next Save |
| 1119 |
console.warn( '[ZIP AI] deferred section-styles flush failed on Save (will retry next Save)', e ); |
| 1120 |
} ); |
| 1121 |
} ); |
| 1122 |
} |
| 1123 |
function onStoreChange() { |
| 1124 |
const sel = resolveStore(); |
| 1125 |
if ( ! sel || typeof sel.isSavingPost !== 'function' ) { |
| 1126 |
return; |
| 1127 |
} |
| 1128 |
// A real (non-autosave) save in flight. |
| 1129 |
const saving = sel.isSavingPost() && |
| 1130 |
! ( typeof sel.isAutosavingPost === 'function' && sel.isAutosavingPost() ); |
| 1131 |
// Transition saving → finished: flush IF the save succeeded (degrade-open |
| 1132 |
// when the selector is absent — a completed save with no failure signal). |
| 1133 |
if ( wasSaving && ! saving ) { |
| 1134 |
const succeeded = typeof sel.didPostSaveRequestSucceed === 'function' |
| 1135 |
? sel.didPostSaveRequestSucceed() |
| 1136 |
: true; |
| 1137 |
if ( succeeded ) { |
| 1138 |
flush(); |
| 1139 |
} |
| 1140 |
} |
| 1141 |
wasSaving = saving; |
| 1142 |
} |
| 1143 |
function arm() { |
| 1144 |
if ( armed ) { |
| 1145 |
return; |
| 1146 |
} |
| 1147 |
armed = true; |
| 1148 |
if ( typeof subscribe === 'function' ) { |
| 1149 |
subscribe( onStoreChange ); |
| 1150 |
} |
| 1151 |
} |
| 1152 |
return { |
| 1153 |
queue ( incoming, postId ) { |
| 1154 |
if ( ! incoming || ! postId ) { |
| 1155 |
return Promise.resolve(); |
| 1156 |
} |
| 1157 |
pending[ postId ] = merge( pending[ postId ] || {}, incoming ); |
| 1158 |
arm(); |
| 1159 |
// Preview the MERGED payload (not just this section) so a second |
| 1160 |
// generate_section doesn't overwrite the single per-post preview |
| 1161 |
// <style> with only its own CSS — earlier sections would go unstyled |
| 1162 |
// until Save otherwise. |
| 1163 |
return Promise.resolve( preview( apiFetch, pending[ postId ], postId ) ).catch( function () {} ); |
| 1164 |
}, |
| 1165 |
// Test seams — the pending set + the store-change handler. |
| 1166 |
_pending: pending, |
| 1167 |
_onStoreChange: onStoreChange, |
| 1168 |
}; |
| 1169 |
} |
| 1170 |
|
| 1171 |
// Lazily-built browser singleton (ONE subscription per editor session), wired to |
| 1172 |
// the live wp.data core/editor store. `queueSectionGbsForSave` is what the |
| 1173 |
// apply-change section path calls. |
| 1174 |
let _sectionGbsSaver = null; |
| 1175 |
function sectionGbsSaver( apiFetch ) { |
| 1176 |
if ( ! _sectionGbsSaver ) { |
| 1177 |
_sectionGbsSaver = createSectionGbsSaver( { |
| 1178 |
apiFetch, |
| 1179 |
editorStore: editorSelect, |
| 1180 |
subscribe ( cb ) { |
| 1181 |
return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.subscribe ) |
| 1182 |
? window.wp.data.subscribe( cb ) |
| 1183 |
: function () {}; |
| 1184 |
}, |
| 1185 |
} ); |
| 1186 |
} |
| 1187 |
return _sectionGbsSaver; |
| 1188 |
} |
| 1189 |
function queueSectionGbsForSave( apiFetch, incoming, postId ) { |
| 1190 |
return sectionGbsSaver( apiFetch ).queue( incoming, postId ); |
| 1191 |
} |
| 1192 |
|
| 1193 |
if ( typeof window !== 'undefined' ) { |
| 1194 |
window.zipwpEditorShared = window.zipwpEditorShared || {}; |
| 1195 |
window.zipwpEditorShared.mergePayload = mergePayload; |
| 1196 |
window.zipwpEditorShared.canvasDoc = canvasDoc; |
| 1197 |
window.zipwpEditorShared.injectCss = injectCss; |
| 1198 |
window.zipwpEditorShared.persistPageGbs = persistPageGbs; |
| 1199 |
window.zipwpEditorShared.previewSectionGbs = previewSectionGbs; |
| 1200 |
window.zipwpEditorShared.createSectionGbsSaver = createSectionGbsSaver; |
| 1201 |
window.zipwpEditorShared.queueSectionGbsForSave = queueSectionGbsForSave; |
| 1202 |
window.zipwpEditorShared.currentPostId = currentPostId; |
| 1203 |
window.zipwpEditorShared.editorSelect = editorSelect; |
| 1204 |
window.zipwpEditorShared.editorDispatch = editorDispatch; |
| 1205 |
window.zipwpEditorShared.currentMeta = currentMeta; |
| 1206 |
window.zipwpEditorShared.blockEditorSelect = blockEditorSelect; |
| 1207 |
window.zipwpEditorShared.blockEditorDispatch = blockEditorDispatch; |
| 1208 |
window.zipwpEditorShared.rootClientId = rootClientId; |
| 1209 |
window.zipwpEditorShared.bannedVisualAttrs = bannedVisualAttrs; |
| 1210 |
window.zipwpEditorShared.isBannedVisualAttr = isBannedVisualAttr; |
| 1211 |
window.zipwpEditorShared.isJsCapable = isJsCapable; |
| 1212 |
window.zipwpEditorShared.referencedIds = referencedIds; |
| 1213 |
window.zipwpEditorShared.classDependents = classDependents; |
| 1214 |
window.zipwpEditorShared.anchorDependents = anchorDependents; |
| 1215 |
} |
| 1216 |
if ( typeof module !== 'undefined' && module.exports ) { |
| 1217 |
module.exports = { |
| 1218 |
mergePayload, |
| 1219 |
canvasDoc, |
| 1220 |
injectCss, |
| 1221 |
persistPageGbs, |
| 1222 |
previewSectionGbs, |
| 1223 |
createSectionGbsSaver, |
| 1224 |
queueSectionGbsForSave, |
| 1225 |
currentPostId, |
| 1226 |
editorSelect, |
| 1227 |
editorDispatch, |
| 1228 |
currentMeta, |
| 1229 |
blockEditorSelect, |
| 1230 |
blockEditorDispatch, |
| 1231 |
isJsCapable, |
| 1232 |
referencedIds, |
| 1233 |
classDependents, |
| 1234 |
anchorDependents, |
| 1235 |
rootClientId, |
| 1236 |
bannedVisualAttrs, |
| 1237 |
isBannedVisualAttr, |
| 1238 |
}; |
| 1239 |
} |
| 1240 |
}() ); |
| 1241 |
|
| 1242 |
|
| 1243 |
/** |
| 1244 |
* ZipWP MCP — Vibe Editing v2: editor/apply-change handler. |
| 1245 |
* |
| 1246 |
* The WRITE sibling of editor/get-context. The brain's AgentBrowserLoop dispatches |
| 1247 |
* a `js_rpc` envelope carrying a serialized Gutenberg EXECUTION PLAN; this handler |
| 1248 |
* runs each operation against the LIVE Gutenberg tree via wp.data dispatch, then |
| 1249 |
* returns a structured reply that the bridge POSTs to /agent/rpc-reply so the loop |
| 1250 |
* folds it IN THE SAME TURN. |
| 1251 |
* |
| 1252 |
* Contract: |
| 1253 |
* args = { version, post_id, operations:[ { function, ...named args } ] } |
| 1254 |
* Each operation maps to ONE-OR-MORE real wp.data.dispatch('core/block-editor')[function](...) |
| 1255 |
* calls — one per op, except moveBlocksToPosition's reorder ('order') and |
| 1256 |
* multi-parent forms, which fan out to N native moveBlocksToPosition dispatches. |
| 1257 |
* Allowed functions: updateBlockAttributes | insertBlocks | removeBlocks | |
| 1258 |
* moveBlocksToPosition | replaceBlocks | replaceInnerBlocks | duplicateBlocks | |
| 1259 |
* selectBlock. The brain drives them with its native Gutenberg API knowledge. |
| 1260 |
* - updateBlockAttributes PRESERVES clientId (read-merge-dispatch; className |
| 1261 |
* verbatim — the GBS JIT renders it; NO style translation). |
| 1262 |
* - insertBlocks / replace* MINT clientIds (createBlock stamps them synchronously) |
| 1263 |
* → returned in applied[].new_client_ids so the brain can target them next turn. |
| 1264 |
* - moveBlocksToPosition PRESERVES clientIds (a splice). insert/move accept a |
| 1265 |
* before|after anchor that resolves to root+index here. |
| 1266 |
* post_id two-tab guard: abort ALL on mismatch (returned as a `refused` reply, |
| 1267 |
* ok:true, so the LLM sees the real reason — not "browser unreachable"). |
| 1268 |
* Partial-apply: a per-op failure (stale clientId) records into failed[] by index |
| 1269 |
* and continues; earlier ops stay applied. Targeting is liveness-checked here; |
| 1270 |
* the brain holds no durable handle. |
| 1271 |
* |
| 1272 |
* @package |
| 1273 |
*/ |
| 1274 |
( function () { |
| 1275 |
'use strict'; |
| 1276 |
|
| 1277 |
function isPlainObject( v ) { |
| 1278 |
return v !== null && typeof v === 'object' && ! Array.isArray( v ); |
| 1279 |
} |
| 1280 |
|
| 1281 |
// L3 boundary — per-block visual styling lives in `className` ONLY (the |
| 1282 |
// Spectra GBS JIT grammar); these flat visual attrs are NEVER allowed on a |
| 1283 |
// block. The brain's zod denylist is the contract owner; we re-enforce |
| 1284 |
// client-side (defense in depth) so a stale/forked brain bundle can't paint |
| 1285 |
// banned attrs into the live tree. The 8-prop list is the SHARED SSOT |
| 1286 |
// (editor-shared-utils.isBannedVisualAttr) so this + get-context can never |
| 1287 |
// drift. If the shared module isn't resolved we don't strip — the brain's zod |
| 1288 |
// already rejected the 8 upstream, so we degrade consistently (never a 3rd copy). |
| 1289 |
function isBannedVisualAttr( key ) { |
| 1290 |
const u = sharedEditorUtils(); |
| 1291 |
return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) ); |
| 1292 |
} |
| 1293 |
function stripBannedAttrs( attrs ) { |
| 1294 |
if ( ! isPlainObject( attrs ) ) { |
| 1295 |
return attrs; |
| 1296 |
} |
| 1297 |
const clean = {}; |
| 1298 |
Object.keys( attrs ).forEach( function ( k ) { |
| 1299 |
if ( ! isBannedVisualAttr( k ) ) { |
| 1300 |
clean[ k ] = attrs[ k ]; |
| 1301 |
} else { |
| 1302 |
console.warn( '[ZIP AI:apply-change] dropped banned visual attr "%s" — styling belongs in className', k ); |
| 1303 |
} |
| 1304 |
} ); |
| 1305 |
return clean; |
| 1306 |
} |
| 1307 |
|
| 1308 |
// Recursively strip banned visual attrs from an already-PARSED block tree |
| 1309 |
// (wp.blocks.parse output). The section_markup insert path bypasses toBlock, |
| 1310 |
// so this is where the block path's per-block stripBannedAttrs is reapplied — |
| 1311 |
// and the ONLY guard on that path (the vibe-editor door never reaches |
| 1312 |
// @bsf/wp-importer's fail-closed validator). Mutates each block's attributes. |
| 1313 |
function stripBannedAttrsDeep( block ) { |
| 1314 |
if ( ! block || typeof block !== 'object' ) { |
| 1315 |
return; |
| 1316 |
} |
| 1317 |
if ( block.attributes ) { |
| 1318 |
block.attributes = stripBannedAttrs( block.attributes ); |
| 1319 |
} |
| 1320 |
if ( Array.isArray( block.innerBlocks ) ) { |
| 1321 |
block.innerBlocks.forEach( stripBannedAttrsDeep ); |
| 1322 |
} |
| 1323 |
} |
| 1324 |
|
| 1325 |
// Shared editor utilities — resolved at call time from the ONE source |
| 1326 |
// (editor/shared/editor-shared-utils.js): window in the browser, require() |
| 1327 |
// under jest. currentPostId (post_id guard) lives there so this handler and |
| 1328 |
// get-context can never drift. A missing module degrades consistently (null) |
| 1329 |
// — never a spurious post_id refusal. |
| 1330 |
function sharedEditorUtils() { |
| 1331 |
if ( typeof window !== 'undefined' && window.zipwpEditorShared ) { |
| 1332 |
return window.zipwpEditorShared; |
| 1333 |
} |
| 1334 |
if ( typeof require === 'function' ) { |
| 1335 |
try { |
| 1336 |
return require( '../shared/editor-shared-utils.js' ); |
| 1337 |
} catch ( e ) { |
| 1338 |
return null; |
| 1339 |
} |
| 1340 |
} |
| 1341 |
return null; |
| 1342 |
} |
| 1343 |
function currentPostId() { |
| 1344 |
const u = sharedEditorUtils(); |
| 1345 |
return u && u.currentPostId ? u.currentPostId() : null; |
| 1346 |
} |
| 1347 |
|
| 1348 |
// Liveness (GAP-C / G34): the clientId must resolve against the live tree. |
| 1349 |
// Throws a typed miss so the caller records it in failed[] and never mutates |
| 1350 |
// the wrong block (a stale id must NEVER default to "top"). |
| 1351 |
function liveness( sel, id ) { |
| 1352 |
const block = sel.getBlock( id ); |
| 1353 |
if ( ! block ) { |
| 1354 |
throw new Error( 'stale_client_id:' + id ); |
| 1355 |
} |
| 1356 |
return block; |
| 1357 |
} |
| 1358 |
|
| 1359 |
// Block-safety guard (SCENARIO-003): a MUTATING op must not touch a block the |
| 1360 |
// editor has LOCKED, nor escape THIS page by editing synced-pattern |
| 1361 |
// (core/block) inner content or template-locked content. Gutenberg's own lock |
| 1362 |
// selectors are authoritative — canEditBlock / canRemoveBlock / canMoveBlock |
| 1363 |
// already fold in template lock, content lock, and synced-pattern instance |
| 1364 |
// locks (the instance's inner blocks are content-locked). A blocked op throws |
| 1365 |
// a typed miss → failed[], so the brain surfaces it (and can tell the user) |
| 1366 |
// instead of silently clobbering shared/locked content. Degrades to ALLOW when |
| 1367 |
// a selector is absent (older Gutenberg) — never a false block. |
| 1368 |
const MUTATION_KIND = { |
| 1369 |
updateBlockAttributes: 'edit', |
| 1370 |
removeBlocks: 'remove', |
| 1371 |
replaceBlocks: 'remove', |
| 1372 |
replaceInnerBlocks: 'edit', |
| 1373 |
moveBlocksToPosition: 'move', |
| 1374 |
}; |
| 1375 |
function assertMutable( sel, id, kind ) { |
| 1376 |
if ( ! id || ! sel.getBlock( id ) ) { |
| 1377 |
return; |
| 1378 |
} // stale / missing — liveness owns that miss |
| 1379 |
const can = kind === 'remove' ? sel.canRemoveBlock |
| 1380 |
: kind === 'move' ? sel.canMoveBlock |
| 1381 |
: sel.canEditBlock; |
| 1382 |
if ( typeof can === 'function' && can.call( sel, id ) === false ) { |
| 1383 |
throw new Error( 'locked_block:' + id ); |
| 1384 |
} |
| 1385 |
} |
| 1386 |
|
| 1387 |
// Insert-time lock guard (GBR-2): insertBlocks / duplicateBlocks ADD a block to |
| 1388 |
// a CONTAINER — a case canEdit/Remove/Move (assertMutable) does NOT cover, so |
| 1389 |
// those two ops previously bypassed the lock check. getTemplateLock on the |
| 1390 |
// destination root is authoritative: 'all' (fully locked) and 'insert' (no |
| 1391 |
// add/remove/move) both forbid the insertion. Throws a typed miss → failed[] so |
| 1392 |
// the brain surfaces it instead of relying on the dispatch to reject. Degrades |
| 1393 |
// to ALLOW when the selector is absent (older Gutenberg) — never a false block. |
| 1394 |
function assertInsertable( sel, root ) { |
| 1395 |
if ( typeof sel.getTemplateLock !== 'function' ) { |
| 1396 |
return; |
| 1397 |
} |
| 1398 |
// eslint-disable-next-line eqeqeq |
| 1399 |
const lock = sel.getTemplateLock( root == null ? '' : root ); |
| 1400 |
if ( lock === 'all' || lock === 'insert' ) { |
| 1401 |
throw new Error( 'locked_block:' + ( root || 'root' ) ); |
| 1402 |
} |
| 1403 |
} |
| 1404 |
|
| 1405 |
// M2 — move-DESTINATION lock guard. canMoveBlock (assertMutable 'move') |
| 1406 |
// folds in the SOURCE parent's lock only; moving INTO a fully-locked |
| 1407 |
// container bypassed every check. templateLock semantics: 'all' forbids any |
| 1408 |
// structural change inside the container; 'insert' forbids add/remove but |
| 1409 |
// PERMITS moving existing children, so only 'all' blocks a move-in here. |
| 1410 |
// Same degrade-to-ALLOW posture as assertInsertable. |
| 1411 |
function assertMoveDestination( sel, root ) { |
| 1412 |
if ( typeof sel.getTemplateLock !== 'function' ) { |
| 1413 |
return; |
| 1414 |
} |
| 1415 |
// eslint-disable-next-line eqeqeq |
| 1416 |
if ( sel.getTemplateLock( root == null ? '' : root ) === 'all' ) { |
| 1417 |
throw new Error( 'locked_block:' + ( root || 'root' ) ); |
| 1418 |
} |
| 1419 |
} |
| 1420 |
|
| 1421 |
// ── Subtree scope-lock (selection confinement — SaaS remediation ①) ─────── |
| 1422 |
// The brain stamps `scope_lock` (the selected container's clientId) on the |
| 1423 |
// envelope ONLY when the turn bound a SUBTREE scope (a CONTAINER is selected |
| 1424 |
// and the request is NOT page-wide). Every MUTATING op must then act on the |
| 1425 |
// locked container or a DESCENDANT of it — editing / inserting / moving into |
| 1426 |
// a DIFFERENT section is refused (→ failed[], so the brain surfaces it and |
| 1427 |
// can tell the user). This is the tree-aware AIRTIGHT half of "stay inside |
| 1428 |
// the selected element"; the brain gate is the soft (outline-only) half. |
| 1429 |
// selectBlock is exempt (navigation, not a mutation). Degrades to ALLOW when |
| 1430 |
// getBlockParents is absent (older Gutenberg) — never a false block; the |
| 1431 |
// caller also drops a stale (non-live) lock id before enforcing. |
| 1432 |
const SCOPE_EXEMPT_FUNCTIONS = { selectBlock: true }; |
| 1433 |
// `id` is undefined/null when a field simply wasn't provided (no constraint |
| 1434 |
// from it); '' is the PAGE ROOT (Gutenberg's own rootClientId convention) |
| 1435 |
// and must NOT short-circuit true — the page root is never inside a |
| 1436 |
// subtree lock. |
| 1437 |
function isWithinScope( sel, id, lockId ) { |
| 1438 |
if ( id === undefined || id === null || id === lockId ) { |
| 1439 |
return true; |
| 1440 |
} |
| 1441 |
if ( id === '' ) { |
| 1442 |
return false; |
| 1443 |
} |
| 1444 |
if ( typeof sel.getBlockParents !== 'function' ) { |
| 1445 |
return true; |
| 1446 |
} // can't tell → allow |
| 1447 |
const parents = sel.getBlockParents( id ) || []; |
| 1448 |
return parents.indexOf( lockId ) !== -1; |
| 1449 |
} |
| 1450 |
// Every clientId a mutating op acts ON or places relative to (the surface a |
| 1451 |
// "roam" would land on): clientIds[], order[], rootClientId, toRootClientId, |
| 1452 |
// and the before/after placement anchors. |
| 1453 |
function scopeGoverningIds( op ) { |
| 1454 |
let ids = []; |
| 1455 |
if ( Array.isArray( op.clientIds ) ) { |
| 1456 |
ids = ids.concat( op.clientIds ); |
| 1457 |
} |
| 1458 |
if ( Array.isArray( op.order ) ) { |
| 1459 |
ids = ids.concat( op.order ); |
| 1460 |
} |
| 1461 |
[ 'rootClientId', 'toRootClientId', 'before', 'after', 'clientId' ].forEach( function ( k ) { |
| 1462 |
// eslint-disable-next-line eqeqeq |
| 1463 |
if ( op[ k ] != null ) { |
| 1464 |
ids.push( op[ k ] ); |
| 1465 |
} |
| 1466 |
} ); |
| 1467 |
return ids.filter( function ( x ) { |
| 1468 |
return typeof x === 'string' && x !== ''; |
| 1469 |
} ); |
| 1470 |
} |
| 1471 |
// The destination container insertBlocks/moveBlocksToPosition will ACTUALLY |
| 1472 |
// land in — mirrors runOp's own resolution exactly (an anchor resolves to |
| 1473 |
// its PARENT via resolveAnchor; an omitted INSERT root defaults to the PAGE |
| 1474 |
// ROOT, ''; an omitted MOVE destination keeps the target's current parent, |
| 1475 |
// so it imposes no destination constraint) — so the scope check can never |
| 1476 |
// see a different landing spot |
| 1477 |
// than the one that really applies. scopeGoverningIds() above checks the |
| 1478 |
// RAW anchor/root id (is the id itself in scope); this checks where that |
| 1479 |
// id/omission actually RESOLVES to, which is a different question for two |
| 1480 |
// reasons: an anchor's parent can be outside scope even when the anchor |
| 1481 |
// itself is in scope (before/after the locked container = its parent, one |
| 1482 |
// level UP), and an OMITTED destination has no raw field for |
| 1483 |
// scopeGoverningIds() to flag at all. What an omission resolves to now |
| 1484 |
// depends on the op: an omitted INSERT root is the page root (never in |
| 1485 |
// scope); an omitted MOVE keeps each target's current parent, so it adds no |
| 1486 |
// destination constraint; an omitted `order` parent is the one those blocks |
| 1487 |
// share, which IS resolved here and scope-checked for real. |
| 1488 |
function resolvedDestinationOf( sel, op ) { |
| 1489 |
const anchor = anchorOf( op ); |
| 1490 |
if ( anchor ) { |
| 1491 |
if ( ! anchor.id || ! sel.getBlock( anchor.id ) ) { |
| 1492 |
return undefined; |
| 1493 |
} // stale anchor — liveness() surfaces this separately |
| 1494 |
return resolveAnchor( sel, anchor.id, anchor.position ).parent; |
| 1495 |
} |
| 1496 |
if ( op.function === 'insertBlocks' ) { |
| 1497 |
// eslint-disable-next-line eqeqeq |
| 1498 |
return op.rootClientId == null ? '' : op.rootClientId; |
| 1499 |
} |
| 1500 |
if ( op.function === 'moveBlocksToPosition' ) { |
| 1501 |
// Mirrors runOp's destination resolution EXACTLY — the invariant this |
| 1502 |
// helper exists for. Explicit null = page root. Omitted: |
| 1503 |
// • the `order` form re-orders inside the parent those blocks SHARE, so |
| 1504 |
// resolve it (orderParentOf) and scope-check it for real. Returning |
| 1505 |
// undefined here would skip the check while runOp still landed |
| 1506 |
// somewhere concrete — the exact drift this helper must not have. |
| 1507 |
// • the targeted form keeps each target in its OWN parent |
| 1508 |
// (KEEP_CURRENT_PARENT), which imposes no single destination, and the |
| 1509 |
// targets themselves are already checked by scopeGoverningIds. |
| 1510 |
if ( op.toRootClientId === null ) { |
| 1511 |
return ''; |
| 1512 |
} |
| 1513 |
if ( op.toRootClientId === undefined ) { |
| 1514 |
return op.order ? orderParentOf( sel, op.order ) : undefined; |
| 1515 |
} |
| 1516 |
return op.toRootClientId; |
| 1517 |
} |
| 1518 |
if ( op.function === 'duplicateBlocks' ) { |
| 1519 |
// A duplicate lands as a SIBLING of the original, so its destination is |
| 1520 |
// the original's PARENT. Without this, duplicating the locked container |
| 1521 |
// itself passed the raw-id check (id === lockId) but the copy escaped to |
| 1522 |
// the lock's parent, outside scope. |
| 1523 |
const first = op.clientIds && op.clientIds[ 0 ]; |
| 1524 |
return first ? ( sel.getBlockRootClientId( first ) || '' ) : undefined; |
| 1525 |
} |
| 1526 |
return undefined; // op has no destination-shaped field |
| 1527 |
} |
| 1528 |
function assertWithinScope( sel, op, lockId ) { |
| 1529 |
// A whole-section ADD (the brain stamps `section_add` on a resolved |
| 1530 |
// section_ref insert) is a PAGE-LEVEL add, not an edit of the selected |
| 1531 |
// container — exempt it from the subtree lock, exactly as the brain's |
| 1532 |
// selection-binding guard does. Narrow by design: only a marked section |
| 1533 |
// insert; edits, moves, and inline-`blocks` inserts stay confined. |
| 1534 |
if ( ! lockId || SCOPE_EXEMPT_FUNCTIONS[ op.function ] || op.section_add === true ) { |
| 1535 |
return; |
| 1536 |
} |
| 1537 |
const ids = scopeGoverningIds( op ); |
| 1538 |
if ( ids.length === 0 ) { |
| 1539 |
// A mutating op with no resolved target = a page-root op (e.g. insert |
| 1540 |
// at root). That lands OUTSIDE the selected container. |
| 1541 |
throw new Error( 'out_of_scope: this edit has no in-section target (it would land at the page root), but you are scoped to the selected container ' + lockId + ' — insert/edit INSIDE it, or tell the user if a different section is intended.' ); |
| 1542 |
} |
| 1543 |
for ( let i = 0; i < ids.length; i++ ) { |
| 1544 |
if ( ! isWithinScope( sel, ids[ i ], lockId ) ) { |
| 1545 |
throw new Error( 'out_of_scope: target ' + ids[ i ] + ' is outside the selected container ' + lockId + ' (and its children). You are scoped to that section — edit inside it, or tell the user if a different section is intended.' ); |
| 1546 |
} |
| 1547 |
} |
| 1548 |
const dest = resolvedDestinationOf( sel, op ); |
| 1549 |
// Duplicating the SELECTED container itself lands the copy as its sibling |
| 1550 |
// (in the parent, one level outside the lock) — that IS the user's intent |
| 1551 |
// when they select a section and say "duplicate this". Exempt ONLY that |
| 1552 |
// exact case (the first duplicated id is the lock root, matching how |
| 1553 |
// resolvedDestinationOf derives `dest`); every other out-of-scope |
| 1554 |
// destination — a DIFFERENT block copied outside — still throws. |
| 1555 |
const duplicatingLockRoot = op.function === 'duplicateBlocks' && |
| 1556 |
Array.isArray( op.clientIds ) && op.clientIds[ 0 ] === lockId; |
| 1557 |
if ( dest !== undefined && ! duplicatingLockRoot && ! isWithinScope( sel, dest, lockId ) ) { |
| 1558 |
throw new Error( 'out_of_scope: this would land at ' + ( dest === '' ? 'the page root' : dest ) + ', outside the selected container ' + lockId + ' — insert/move INSIDE it, or tell the user if a different section is intended.' ); |
| 1559 |
} |
| 1560 |
} |
| 1561 |
|
| 1562 |
// The block's editable-content attribute(s), from the block REGISTRY — no |
| 1563 |
// hardcoded per-type table. Core blocks declare content via a `source` of |
| 1564 |
// 'rich-text'/'html' (core/heading→content, core/button→text). Spectra blocks |
| 1565 |
// store content in a CUSTOM attribute with no standard source, so we fall |
| 1566 |
// back to a declared `content`/`text` attribute (the Spectra convention, |
| 1567 |
// e.g. spectra/content→text). Block types with neither (image, spacer) get |
| 1568 |
// an empty list and are left untouched. |
| 1569 |
function richTextAttrsOf( name ) { |
| 1570 |
const t = window.wp.blocks && window.wp.blocks.getBlockType |
| 1571 |
? window.wp.blocks.getBlockType( name ) |
| 1572 |
: null; |
| 1573 |
const attrs = t && t.attributes ? t.attributes : {}; |
| 1574 |
const out = []; |
| 1575 |
for ( const k in attrs ) { |
| 1576 |
if ( ! Object.prototype.hasOwnProperty.call( attrs, k ) ) { |
| 1577 |
continue; |
| 1578 |
} |
| 1579 |
const src = attrs[ k ] && attrs[ k ].source; |
| 1580 |
if ( src === 'rich-text' || src === 'html' ) { |
| 1581 |
out.push( k ); |
| 1582 |
} |
| 1583 |
} |
| 1584 |
if ( out.length === 0 ) { |
| 1585 |
[ 'content', 'text' ].forEach( function ( k ) { |
| 1586 |
if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) { |
| 1587 |
out.push( k ); |
| 1588 |
} |
| 1589 |
} ); |
| 1590 |
} |
| 1591 |
return out; |
| 1592 |
} |
| 1593 |
|
| 1594 |
// The block's REGISTRY attribute schema — the SSOT for which attrs are valid |
| 1595 |
// on a given block type. getBlockType(name).attributes is the exact set |
| 1596 |
// Gutenberg itself validates against; an attr absent from this set is one |
| 1597 |
// updateBlockAttributes would silently ignore. Returns null when the type |
| 1598 |
// isn't registered (a forked/unknown block) — caller then can't partition, so |
| 1599 |
// it applies everything (degrade open, never block a write on a missing type). |
| 1600 |
function registeredAttrKeysOf( name ) { |
| 1601 |
const t = window.wp.blocks && window.wp.blocks.getBlockType |
| 1602 |
? window.wp.blocks.getBlockType( name ) |
| 1603 |
: null; |
| 1604 |
if ( ! t || ! t.attributes ) { |
| 1605 |
return null; |
| 1606 |
} |
| 1607 |
return Object.keys( t.attributes ); |
| 1608 |
} |
| 1609 |
|
| 1610 |
// Partition an incoming attrs object against the block's REGISTRY schema: |
| 1611 |
// { valid, unknown }. `valid` is the subset whose keys exist in |
| 1612 |
// getBlockType(name).attributes (the keys Gutenberg will actually accept); |
| 1613 |
// `unknown` is the keys with no schema entry (Gutenberg would silently drop |
| 1614 |
// them). `className` is always valid (Gutenberg's universal block-support |
| 1615 |
// attr, not always re-declared per type). When the type isn't registered we |
| 1616 |
// can't partition — return everything as valid (degrade open). Surfacing |
| 1617 |
// `unknown` in the reply lets the brain LEARN that attr X isn't valid for |
| 1618 |
// block Y, instead of inferring success from a silent ignore. |
| 1619 |
function partitionAttrs( name, attrs ) { |
| 1620 |
const valid = {}; |
| 1621 |
const unknown = []; |
| 1622 |
if ( ! isPlainObject( attrs ) ) { |
| 1623 |
return { valid, unknown }; |
| 1624 |
} |
| 1625 |
const registered = registeredAttrKeysOf( name ); |
| 1626 |
Object.keys( attrs ).forEach( function ( k ) { |
| 1627 |
if ( registered === null || k === 'className' || registered.indexOf( k ) !== -1 ) { |
| 1628 |
valid[ k ] = attrs[ k ]; |
| 1629 |
} else { |
| 1630 |
unknown.push( k ); |
| 1631 |
} |
| 1632 |
} ); |
| 1633 |
return { valid, unknown }; |
| 1634 |
} |
| 1635 |
|
| 1636 |
// Route a generic content/text value onto the block's real rich-text attr(s). |
| 1637 |
// `content`/`text` are universal authoring ALIASES — the model writes one and |
| 1638 |
// we copy it onto whatever rich-text attr the block actually declares (e.g. |
| 1639 |
// spectra/content -> `text`, core/heading -> `content`). After routing, DROP |
| 1640 |
// any consumed alias the block does NOT itself declare, so a `content` the |
| 1641 |
// model sent for a `text`-block isn't later flagged as an unknown attr (it was |
| 1642 |
// applied, just under the real key) — a false-positive that would mis-fire the |
| 1643 |
// unknown_attrs nudge. |
| 1644 |
// Rich-text attrs that hold an ATTRIBUTION rather than the block's body copy, so |
| 1645 |
// a generic `content`/`text` rewrite must never be routed into them (core/quote |
| 1646 |
// and core/pullquote declare `citation` alongside `value`). |
| 1647 |
const ATTRIBUTION_RICH_TEXT_ATTRS = [ 'citation' ]; |
| 1648 |
|
| 1649 |
function placeContent( name, attributes ) { |
| 1650 |
const a = Object.assign( {}, attributes || {} ); |
| 1651 |
const body = typeof a.content === 'string' && a.content !== '' ? a.content |
| 1652 |
: typeof a.text === 'string' && a.text !== '' ? a.text : undefined; |
| 1653 |
if ( body === undefined ) { |
| 1654 |
return a; |
| 1655 |
} |
| 1656 |
const targets = richTextAttrsOf( name ); |
| 1657 |
// Route to ONE attr, never every rich-text attr the block declares. A block |
| 1658 |
// with two (core/quote and core/pullquote both declare `value` AND `citation` |
| 1659 |
// as source:html) got the SAME body copied into both, so "rewrite this quote" |
| 1660 |
// silently overwrote the attribution with the quote text — a content loss that |
| 1661 |
// reports clean, on both the insert and update paths. |
| 1662 |
// Prefer the alias the MODEL actually wrote when the block declares it, else |
| 1663 |
// the block's first NON-ATTRIBUTION rich-text attr. For every |
| 1664 |
// single-rich-text-attr block this resolves to exactly what the fan-out |
| 1665 |
// produced, so nothing else moves. |
| 1666 |
const usedAlias = typeof a.content === 'string' && a.content !== '' ? 'content' : 'text'; |
| 1667 |
let primary; |
| 1668 |
if ( targets.indexOf( usedAlias ) !== -1 ) { |
| 1669 |
primary = usedAlias; |
| 1670 |
} else { |
| 1671 |
// Taking targets[0] alone would trust REGISTRY ENUMERATION ORDER to put the |
| 1672 |
// body attr ahead of the attribution attr. core/quote and core/pullquote |
| 1673 |
// happen to declare `value` first, so it is right for them — but a block |
| 1674 |
// (third-party, or a future core revision) declaring the attribution first |
| 1675 |
// would route the rewrite body straight into it: the same silent |
| 1676 |
// content-swap this routing exists to prevent, one block type over. Skip |
| 1677 |
// the known attribution attrs explicitly instead of relying on position. |
| 1678 |
for ( let ti = 0; ti < targets.length; ti++ ) { |
| 1679 |
if ( ATTRIBUTION_RICH_TEXT_ATTRS.indexOf( targets[ ti ] ) === -1 ) { |
| 1680 |
primary = targets[ ti ]; |
| 1681 |
break; |
| 1682 |
} |
| 1683 |
} |
| 1684 |
// Every rich-text attr IS an attribution attr (no body attr on this block) |
| 1685 |
// — fall back to the first so the write still lands somewhere rather than |
| 1686 |
// being silently dropped. |
| 1687 |
if ( primary === undefined ) { |
| 1688 |
primary = targets[ 0 ]; |
| 1689 |
} |
| 1690 |
} |
| 1691 |
if ( primary !== undefined && ( a[ primary ] === undefined || a[ primary ] === '' ) ) { |
| 1692 |
a[ primary ] = body; |
| 1693 |
} |
| 1694 |
[ 'content', 'text' ].forEach( function ( alias ) { |
| 1695 |
if ( targets.indexOf( alias ) === -1 ) { |
| 1696 |
delete a[ alias ]; |
| 1697 |
} |
| 1698 |
} ); |
| 1699 |
return a; |
| 1700 |
} |
| 1701 |
|
| 1702 |
// Plain text the block renders — its rich-text attrs concatenated, tags |
| 1703 |
// stripped. Used for the effectiveness check + the result_text the reply |
| 1704 |
// surfaces so the brain SEES what actually landed. |
| 1705 |
function renderedTextOf( block ) { |
| 1706 |
const attrs = ( block && block.attributes ) || {}; |
| 1707 |
let s = ''; |
| 1708 |
richTextAttrsOf( block && block.name ).forEach( function ( k ) { |
| 1709 |
// Core rich-text attrs become RichTextData OBJECTS after createBlock |
| 1710 |
// (not strings); String() yields their text. Spectra stores plain |
| 1711 |
// strings. A `typeof === "string"` check misses the core case and |
| 1712 |
// falsely fires assertEffectiveContent's empty_content (live-found). |
| 1713 |
const v = attrs[ k ]; |
| 1714 |
if ( v !== null && v !== undefined && v !== '' ) { |
| 1715 |
s += ' ' + String( v ); |
| 1716 |
} |
| 1717 |
} ); |
| 1718 |
return s.replace( /<[^>]*>/g, '' ).trim(); |
| 1719 |
} |
| 1720 |
|
| 1721 |
function intendedContent( spec ) { |
| 1722 |
const a = ( spec && spec.attributes ) || {}; |
| 1723 |
return ( typeof a.content === 'string' && a.content !== '' ) || |
| 1724 |
( typeof a.text === 'string' && a.text !== '' ); |
| 1725 |
} |
| 1726 |
|
| 1727 |
// A CONTENT rewrite that would DROP inline markup — the "make it 4/5" flatten. |
| 1728 |
// True when the block's CURRENT value carries real inline markup (a tag, not a |
| 1729 |
// bare entity) and the incoming value is PLAIN text. get-context surfaces the |
| 1730 |
// rich `html` precisely so a rewrite re-authors the SAME inline structure; a |
| 1731 |
// plain string instead silently strips the design pattern (the large number's |
| 1732 |
// styling, an emphasis span, per-word colours). PURE so a unit test locks it. |
| 1733 |
const CONTENT_TAG_RE = /<[a-z!/][^>]*>/i; |
| 1734 |
function contentWouldFlatten( oldVal, newVal ) { |
| 1735 |
// Only a PLAIN-text overwrite can flatten. A core RichTextData old value is |
| 1736 |
// an object whose String() yields its HTML — coerce so both block families |
| 1737 |
// are covered; the incoming model value is always a plain JSON string. |
| 1738 |
if ( typeof newVal !== 'string' ) { |
| 1739 |
return false; |
| 1740 |
} |
| 1741 |
const oldStr = ( oldVal === null || oldVal === undefined ) ? '' : String( oldVal ); |
| 1742 |
return CONTENT_TAG_RE.test( oldStr ) && ! CONTENT_TAG_RE.test( newVal ); |
| 1743 |
} |
| 1744 |
|
| 1745 |
// Fail CLOSED before dispatch when an updateBlockAttributes content write would |
| 1746 |
// flatten the block's existing inline markup. The message steers recovery to |
| 1747 |
// the correct path (re-author the rich `html` from get_context, keeping the |
| 1748 |
// inline structure) rather than letting a lossy plain-text write report a false |
| 1749 |
// success. Symmetric with assertEffectiveContent's empty_content guard. |
| 1750 |
function assertNoContentFlatten( block, normAttrs ) { |
| 1751 |
if ( ! block || ! normAttrs ) { |
| 1752 |
return; |
| 1753 |
} |
| 1754 |
const contentKeys = richTextAttrsOf( block.name ); |
| 1755 |
const current = block.attributes || {}; |
| 1756 |
contentKeys.forEach( function ( key ) { |
| 1757 |
if ( ! Object.prototype.hasOwnProperty.call( normAttrs, key ) ) { |
| 1758 |
return; |
| 1759 |
} |
| 1760 |
if ( contentWouldFlatten( current[ key ], normAttrs[ key ] ) ) { |
| 1761 |
throw new Error( |
| 1762 |
'content_flatten:' + block.name + ':' + key + |
| 1763 |
'. This block\'s content carries inline formatting that a plain-text value would DROP' + |
| 1764 |
' (the design pattern: styled spans / emphasis). Re-read the block with editor__get_context' + |
| 1765 |
' and rewrite its `html` (the rich value) with your new words INSIDE the same inline structure,' + |
| 1766 |
' not a flat string.', |
| 1767 |
); |
| 1768 |
} |
| 1769 |
} ); |
| 1770 |
} |
| 1771 |
|
| 1772 |
// A spec that ASKED for text content but produced an EMPTY block is a silent |
| 1773 |
// content loss — fail it LOUD (before dispatch) so the brain never reports a |
| 1774 |
// false success on an empty insert/replace. Generalized: it compares INTENT |
| 1775 |
// (the spec carried content) against RESULT (the built block renders no text |
| 1776 |
// and has no children) — never a per-block-type rule. |
| 1777 |
function assertEffectiveContent( specs, blocks ) { |
| 1778 |
( specs || [] ).forEach( function ( spec, i ) { |
| 1779 |
const blk = blocks[ i ]; |
| 1780 |
if ( ! intendedContent( spec ) || ! blk ) { |
| 1781 |
return; |
| 1782 |
} |
| 1783 |
const hasChildren = blk.innerBlocks && blk.innerBlocks.length > 0; |
| 1784 |
if ( renderedTextOf( blk ) === '' && ! hasChildren ) { |
| 1785 |
throw new Error( 'empty_content:' + ( spec.name || 'block' ) ); |
| 1786 |
} |
| 1787 |
} ); |
| 1788 |
} |
| 1789 |
|
| 1790 |
// JSON block-spec → Gutenberg block (recursive). NOT the markup-string path. |
| 1791 |
// Applies ONLY the attrs the block's registry declares (partitionAttrs, the |
| 1792 |
// getBlockType SSOT) and pushes the keys it doesn't into `unknownSink` (when |
| 1793 |
// provided) — recursively across innerBlocks. The brain folds every attr |
| 1794 |
// through with no allowlist; this is where insert/replace decide validity and |
| 1795 |
// report back what they dropped, exactly like the setAttributes path (real |
| 1796 |
// Gutenberg's createBlock already sanitizes undeclared attrs away — silently; |
| 1797 |
// collecting them here is what makes the drop visible to the brain). |
| 1798 |
function toBlock( spec, unknownSink ) { |
| 1799 |
// Insert-time block-name gate (SaaS remediation plan E2E-F1, |
| 1800 |
// live-observed 2026-06-11): an UNREGISTERED name must fail the op |
| 1801 |
// typed-and-loud — createBlock would otherwise mint a missing-block |
| 1802 |
// placeholder ("Unsupported block") the user has to delete by hand |
| 1803 |
// (the model invented `core/input`/`core/textarea` and the page |
| 1804 |
// collected junk until read-back). Same feedback philosophy as |
| 1805 |
// unknown attrs: structured, model-actionable, never silent. |
| 1806 |
if ( ! ( window.wp.blocks && |
| 1807 |
window.wp.blocks.getBlockType && |
| 1808 |
window.wp.blocks.getBlockType( spec.name ) ) ) { |
| 1809 |
throw new Error( |
| 1810 |
'unregistered_block_type: "' + spec.name + '" is not a registered block on this site — ' + |
| 1811 |
'never invent block names. Use registered blocks (spectra/container, spectra/content, ' + |
| 1812 |
'core/paragraph, …) or embed a real form via { "name": "srfm/form", "attrs": { "id": <formId> } }.' |
| 1813 |
); |
| 1814 |
} |
| 1815 |
const inner = ( spec.innerBlocks || [] ).map( function ( s ) { |
| 1816 |
return toBlock( s, unknownSink ); |
| 1817 |
} ); |
| 1818 |
// S4 (PR #282): strip the 8 GBS-banned visual attrs FIRST, exactly like the |
| 1819 |
// updateBlockAttributes path (normalizePlanAttrs) — so insert/replace can't |
| 1820 |
// paint a banned visual attr into a freshly-minted block even if a stale/ |
| 1821 |
// forked brain bundle folds one through. Defense-in-depth behind the brain |
| 1822 |
// blockSpec zod; symmetric with the update path. |
| 1823 |
const parts = partitionAttrs( spec.name, placeContent( spec.name, stripBannedAttrs( spec.attributes ) ) ); |
| 1824 |
if ( unknownSink && parts.unknown.length ) { |
| 1825 |
Array.prototype.push.apply( unknownSink, parts.unknown ); |
| 1826 |
} |
| 1827 |
return window.wp.blocks.createBlock( spec.name, parts.valid, inner ); |
| 1828 |
} |
| 1829 |
|
| 1830 |
// updateBlockAttributes shallow-merges at the TOP key only, so deep-merge |
| 1831 |
// plain-object values (layout, responsiveControls) to keep sibling nested keys. |
| 1832 |
function deepMerge( cur, partial ) { |
| 1833 |
const out = Object.assign( {}, cur ); |
| 1834 |
for ( const k in partial ) { |
| 1835 |
if ( ! Object.prototype.hasOwnProperty.call( partial, k ) ) { |
| 1836 |
continue; |
| 1837 |
} |
| 1838 |
const pv = partial[ k ]; |
| 1839 |
const cv = cur ? cur[ k ] : undefined; |
| 1840 |
out[ k ] = isPlainObject( pv ) && isPlainObject( cv ) ? deepMerge( cv, pv ) : pv; |
| 1841 |
} |
| 1842 |
return out; |
| 1843 |
} |
| 1844 |
|
| 1845 |
// INVARIANT — gs-* identity is immutable on a className write. updateBlockAttributes |
| 1846 |
// replaces className wholesale, so an incoming string keeps the block's CURRENT gs-* |
| 1847 |
// tokens verbatim and contributes only its NON-gs (utility) tokens; foreign gs-* are |
| 1848 |
// dropped. Prevents the Edit-5 clobber (a paragraph's `gs-0a6168-text` overwritten by |
| 1849 |
// a heading's `gs-ae2ba6-h1`) — the executor owns identity, the model owns utilities. |
| 1850 |
function reconcileClassName( currentClassName, incomingClassName ) { |
| 1851 |
const isGs = function ( t ) { |
| 1852 |
return t.indexOf( 'gs-' ) === 0; |
| 1853 |
}; |
| 1854 |
const toTokens = function ( s ) { |
| 1855 |
// eslint-disable-next-line eqeqeq |
| 1856 |
return String( s == null ? '' : s ).trim().split( /\s+/ ).filter( Boolean ); |
| 1857 |
}; |
| 1858 |
const curGs = toTokens( currentClassName ).filter( isGs ); |
| 1859 |
const incUtil = toTokens( incomingClassName ).filter( function ( t ) { |
| 1860 |
return ! isGs( t ); |
| 1861 |
} ); |
| 1862 |
const seen = Object.create( null ); |
| 1863 |
return curGs.concat( incUtil ).filter( function ( t ) { |
| 1864 |
if ( seen[ t ] ) { |
| 1865 |
return false; |
| 1866 |
} |
| 1867 |
seen[ t ] = true; |
| 1868 |
return true; |
| 1869 |
} ).join( ' ' ); |
| 1870 |
} |
| 1871 |
|
| 1872 |
// Every listed block must actually be a child of `parent`. A re-order is only |
| 1873 |
// meaningful among SIBLINGS, and Gutenberg does NOT refuse a non-child: with |
| 1874 |
// from === to it takes its same-parent branch and computes |
| 1875 |
// `subState.indexOf(clientIds[0])`, which is -1 for a stranger. The resulting |
| 1876 |
// moveTo(order, -1, i) splices from the END, so the container's LAST child is |
| 1877 |
// displaced while the block the plan actually named never moves — and the op |
| 1878 |
// reports success. Refuse instead, on BOTH order paths: the derived parent |
| 1879 |
// (below) and the caller-supplied one, which had no check at all. |
| 1880 |
function assertOrderSiblings( sel, order, parent ) { |
| 1881 |
( order || [] ).forEach( function ( id ) { |
| 1882 |
if ( ( sel.getBlockRootClientId( id ) || '' ) !== parent ) { |
| 1883 |
throw new Error( |
| 1884 |
'order_parent_mismatch: block ' + id + ' is not a child of the container being ' + |
| 1885 |
're-ordered, so there is no single container to re-order these in. Re-order only ' + |
| 1886 |
'siblings, or move them one at a time.' |
| 1887 |
); |
| 1888 |
} |
| 1889 |
} ); |
| 1890 |
} |
| 1891 |
|
| 1892 |
// The parent a bare `order` re-order should happen INSIDE — the parent the listed |
| 1893 |
// blocks already share. '' (the page root) is a legitimate answer for a top-level |
| 1894 |
// re-order. Falls back to '' only if the first id can't be resolved, which |
| 1895 |
// liveness() surfaces separately. |
| 1896 |
function orderParentOf( sel, order ) { |
| 1897 |
const ids = order || []; |
| 1898 |
const first = ids[ 0 ]; |
| 1899 |
if ( ! first ) { |
| 1900 |
return ''; |
| 1901 |
} |
| 1902 |
// Adopting the first id's parent when the others don't share it would |
| 1903 |
// RELOCATE them into it — the very re-parenting this resolution exists to |
| 1904 |
// prevent. (The brain now requires an explicit parent, so the derived path |
| 1905 |
// is only reachable from an older or hand-built envelope.) |
| 1906 |
const parent = sel.getBlockRootClientId( first ) || ''; |
| 1907 |
assertOrderSiblings( sel, ids, parent ); |
| 1908 |
return parent; |
| 1909 |
} |
| 1910 |
|
| 1911 |
// Realize an explicit child order within `parent` (reorder). Place each id at |
| 1912 |
// its target index in sequence — earlier indices are already correct, so each |
| 1913 |
// move lands the next block at position i. |
| 1914 |
function reorderChildren( sel, dis, parent, order ) { |
| 1915 |
// M2 — the order form previously bypassed EVERY lock check (its targets |
| 1916 |
// ride `order`, not `clientIds`, so the runOp pre-check resolved no |
| 1917 |
// targets). canMoveBlock per child folds in the parent's template/content |
| 1918 |
// locks — a locked container's reorder now fails typed instead of |
| 1919 |
// silently applying. |
| 1920 |
order.forEach( function ( id ) { |
| 1921 |
liveness( sel, id ); |
| 1922 |
assertMutable( sel, id, 'move' ); |
| 1923 |
} ); |
| 1924 |
// Same omitted-vs-null distinction the move path makes (KEEP_CURRENT_PARENT). |
| 1925 |
// Collapsing both to '' re-parented EVERY listed block to the page root, so a |
| 1926 |
// plain "re-order these cards" un-nested the whole row. Explicit null still |
| 1927 |
// means the page root; omitted means "the parent these blocks already share". |
| 1928 |
let root; |
| 1929 |
if ( parent === null ) { |
| 1930 |
root = ''; |
| 1931 |
assertOrderSiblings( sel, order, root ); |
| 1932 |
} else if ( parent === undefined ) { |
| 1933 |
root = orderParentOf( sel, order ); |
| 1934 |
} else { |
| 1935 |
root = parent; |
| 1936 |
// An EXPLICIT parent was trusted blindly, so a plan naming a container |
| 1937 |
// the blocks don't belong to silently mangled that container instead of |
| 1938 |
// failing (see assertOrderSiblings). Same check, same refusal. |
| 1939 |
assertOrderSiblings( sel, order, root ); |
| 1940 |
} |
| 1941 |
for ( let i = 0; i < order.length; i++ ) { |
| 1942 |
// Undo coalescing: each move after the first merges into the same |
| 1943 |
// undo level so the whole reorder reverts in one ⌘Z. (The caller |
| 1944 |
// already marked the first dispatch when this isn't the batch's |
| 1945 |
// first change.) |
| 1946 |
if ( i > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) { |
| 1947 |
dis.__unstableMarkNextChangeAsNotPersistent(); |
| 1948 |
} |
| 1949 |
dis.moveBlocksToPosition( [ order[ i ] ], root, root, i ); |
| 1950 |
} |
| 1951 |
} |
| 1952 |
|
| 1953 |
// L5 — THE single documented use of a private Gutenberg API in this |
| 1954 |
// codebase. `__unstableMarkNextChangeAsNotPersistent` coalesces undo levels |
| 1955 |
// (one ⌘Z per plan) and keeps typewriter ticks out of the undo stack. |
| 1956 |
// DEGRADATION CONTRACT: it is feature-checked everywhere; if a future |
| 1957 |
// Gutenberg removes/renames it, every call becomes a no-op and behaviour |
| 1958 |
// degrades to MORE undo levels (one per dispatch) — correct, just noisier. |
| 1959 |
// Nothing may ever depend on it for correctness, only for undo ergonomics. |
| 1960 |
function markNonPersistent( dis ) { |
| 1961 |
if ( dis && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) { |
| 1962 |
dis.__unstableMarkNextChangeAsNotPersistent(); |
| 1963 |
} |
| 1964 |
} |
| 1965 |
|
| 1966 |
// H4 — saving is LOCKED while a typewriter stream is live. The persistent |
| 1967 |
// full text is applied by runOp BEFORE the stream starts, but the stream |
| 1968 |
// then clears the attr and re-types it word-by-word — so for words×28ms the |
| 1969 |
// LIVE edited attribute is partial. A manual Ctrl+S or the 60s autosave in |
| 1970 |
// that window serializes the truncated text into the post (non-persistent |
| 1971 |
// suppresses the undo level, NOT what getEditedPostContent reads). Lock |
| 1972 |
// via the core/editor store while ANY stream is active; unlock on drain. |
| 1973 |
// Feature-checked: an editor without lockPostSaving keeps today's window |
| 1974 |
// (no false lock, no throw). |
| 1975 |
const TYPEWRITER_SAVE_LOCK = 'zipwp-vibe-typewriter'; |
| 1976 |
function lockSavingForTypewriter() { |
| 1977 |
try { |
| 1978 |
const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' ); |
| 1979 |
if ( ed && typeof ed.lockPostSaving === 'function' ) { |
| 1980 |
ed.lockPostSaving( TYPEWRITER_SAVE_LOCK ); |
| 1981 |
} |
| 1982 |
} catch ( e ) { /* never break the apply on a lock failure */ } |
| 1983 |
} |
| 1984 |
function unlockSavingForTypewriter() { |
| 1985 |
try { |
| 1986 |
const ed = window.wp && window.wp.data && window.wp.data.dispatch( 'core/editor' ); |
| 1987 |
if ( ed && typeof ed.unlockPostSaving === 'function' ) { |
| 1988 |
ed.unlockPostSaving( TYPEWRITER_SAVE_LOCK ); |
| 1989 |
} |
| 1990 |
} catch ( e ) { /* unlock is best-effort; the lock name is idempotent */ } |
| 1991 |
} |
| 1992 |
|
| 1993 |
// --- Typewriter (streaming text effect) ------------------------------- |
| 1994 |
// ANY text the agent writes — a rewrite (updateBlockAttributes) OR new copy in |
| 1995 |
// an inserted/replaced block — STREAMS in word-by-word instead of snapping. |
| 1996 |
// Ms between words; named single cadence source. |
| 1997 |
const TYPEWRITER_WORD_MS = 28; |
| 1998 |
|
| 1999 |
// Animation off when the host can't animate (no matchMedia — e.g. jsdom under |
| 2000 |
// jest) or the user prefers reduced motion. Keeps the persistent full content. |
| 2001 |
function typewriterDisabled() { |
| 2002 |
try { |
| 2003 |
return ! window.matchMedia || |
| 2004 |
window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; |
| 2005 |
} catch ( e ) { |
| 2006 |
return true; |
| 2007 |
} |
| 2008 |
} |
| 2009 |
|
| 2010 |
// Current rich-text content of a block (the first content attr that HAS text), |
| 2011 |
// '' if none. The op-agnostic text identity we diff before/after an op. |
| 2012 |
function contentOf( blk ) { |
| 2013 |
const cattr = firstRichTextWithContent( blk ); |
| 2014 |
return cattr ? String( blk.attributes[ cattr ] ) : ''; |
| 2015 |
} |
| 2016 |
|
| 2017 |
// Every clientId an op NAMES as an operand — generic id-bearing fields ONLY, no |
| 2018 |
// switch on op.function. The before/after text diff over these (+ newly minted |
| 2019 |
// ids) is what decides what streams, so no operation is ever special-cased. |
| 2020 |
function opOperandIds( op ) { |
| 2021 |
let ids = []; |
| 2022 |
if ( op ) { |
| 2023 |
if ( Array.isArray( op.clientIds ) ) { |
| 2024 |
ids = ids.concat( op.clientIds ); |
| 2025 |
} |
| 2026 |
// eslint-disable-next-line eqeqeq |
| 2027 |
if ( op.rootClientId != null ) { |
| 2028 |
ids.push( op.rootClientId ); |
| 2029 |
} |
| 2030 |
// eslint-disable-next-line eqeqeq |
| 2031 |
if ( op.clientId != null ) { |
| 2032 |
ids.push( op.clientId ); |
| 2033 |
} |
| 2034 |
} |
| 2035 |
return ids; |
| 2036 |
} |
| 2037 |
|
| 2038 |
// First rich-text content attr of a block that actually HAS text — used to find |
| 2039 |
// what to stream in a freshly inserted/replaced block. |
| 2040 |
function firstRichTextWithContent( blk ) { |
| 2041 |
const cattrs = richTextAttrsOf( blk.name ); |
| 2042 |
for ( let i = 0; i < cattrs.length; i++ ) { |
| 2043 |
const k = cattrs[ i ]; |
| 2044 |
const v = ( blk.attributes || {} )[ k ]; |
| 2045 |
if ( typeof v === 'string' && v.trim() ) { |
| 2046 |
return k; |
| 2047 |
} |
| 2048 |
} |
| 2049 |
return null; |
| 2050 |
} |
| 2051 |
|
| 2052 |
// Active streams + the SINGLE shared ticker that advances them all together, so |
| 2053 |
// a BULK operation (many blocks at once) types CONCURRENTLY off one timer. |
| 2054 |
let _twStreams = []; |
| 2055 |
let _twTimer = null; |
| 2056 |
function _twTick() { |
| 2057 |
let typing = false; |
| 2058 |
for ( let s = 0; s < _twStreams.length; s++ ) { |
| 2059 |
const st = _twStreams[ s ]; |
| 2060 |
if ( st.i >= st.tokens.length ) { |
| 2061 |
continue; |
| 2062 |
} |
| 2063 |
// Liveness re-check: the block can vanish mid-stream (a later op in the |
| 2064 |
// same plan removed/replaced it, or the user deleted it). Writing to a |
| 2065 |
// gone block is a wasted dispatch that can warn — drop the stream. |
| 2066 |
if ( st.sel && ! st.sel.getBlock( st.id ) ) { |
| 2067 |
st.i = st.tokens.length; continue; |
| 2068 |
} |
| 2069 |
// H4 — a throw from updateBlockAttributes must NOT escape the tick: |
| 2070 |
// it would skip the drain/unlock below and leave post-saving locked |
| 2071 |
// for the rest of the session (the whole page becomes unsavable). |
| 2072 |
// The persistent full content is already applied by the caller, so a |
| 2073 |
// failed cosmetic type-in is safe to abandon — drop just that stream. |
| 2074 |
try { |
| 2075 |
st.acc += st.tokens[ st.i ]; |
| 2076 |
st.i++; |
| 2077 |
markNonPersistent( st.dis ); // VISUAL only — never an undo level |
| 2078 |
const a = {}; |
| 2079 |
a[ st.contentAttr ] = st.acc; |
| 2080 |
st.dis.updateBlockAttributes( st.id, a ); |
| 2081 |
} catch ( e ) { |
| 2082 |
console.warn( '[ZIP AI] typewriter stream aborted for block', st.id, e ); // eslint-disable-line no-console -- intentional error surfacing |
| 2083 |
st.i = st.tokens.length; // mark done so the drain path runs |
| 2084 |
continue; |
| 2085 |
} |
| 2086 |
if ( st.i < st.tokens.length ) { |
| 2087 |
typing = true; |
| 2088 |
} |
| 2089 |
} |
| 2090 |
if ( typing ) { |
| 2091 |
_twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS ); return; |
| 2092 |
} |
| 2093 |
_twStreams = []; |
| 2094 |
_twTimer = null; |
| 2095 |
// H4 — every stream drained: the live attrs hold full text again, so |
| 2096 |
// saving is safe. (Unlock here, the ONLY drain point.) |
| 2097 |
unlockSavingForTypewriter(); |
| 2098 |
} |
| 2099 |
|
| 2100 |
// VISUAL typewriter overlay for block `id`'s content attr. The REAL change (the |
| 2101 |
// full `target` content) is ALREADY applied PERSISTENTLY by the caller; this |
| 2102 |
// only adds a non-persistent type-in, so it NEVER touches undo and works the |
| 2103 |
// same for a rewrite or a freshly-inserted block. Clears the content |
| 2104 |
// SYNCHRONOUSLY (same frame as the op → no flash of the full text) then types |
| 2105 |
// it back word-by-word off the shared ticker. No-op when animation is disabled |
| 2106 |
// or the text is too short (leaves the persistent full content). |
| 2107 |
function typewriterStream( dis, sel, id, contentAttr, target ) { |
| 2108 |
if ( typewriterDisabled() ) { |
| 2109 |
return; |
| 2110 |
} |
| 2111 |
// eslint-disable-next-line eqeqeq |
| 2112 |
const tokens = String( target != null ? target : '' ).split( /(\s+)/ ); |
| 2113 |
if ( tokens.length <= 2 ) { |
| 2114 |
return; |
| 2115 |
} |
| 2116 |
// Same-block dedup: if a stream for this block+attr is already active (an |
| 2117 |
// earlier op in the plan touched the same block), DROP it — two tickers |
| 2118 |
// writing one block's content fight and garble. Last write wins. |
| 2119 |
_twStreams = _twStreams.filter( function ( st ) { |
| 2120 |
return ! ( st.id === id && st.contentAttr === contentAttr ); |
| 2121 |
} ); |
| 2122 |
// H4 — clear FIRST, then take the save-lock. If markNonPersistent or the |
| 2123 |
// clear dispatch throws we have NOT locked yet, so the exception |
| 2124 |
// propagates cleanly with no lock to leak (the earlier version locked |
| 2125 |
// first, so a throwing clear left post-saving locked for the whole |
| 2126 |
// session — the exact bug this guards). Once the clear succeeds the live |
| 2127 |
// attribute is blank; lock on the very next statement (no async gap, so |
| 2128 |
// no save can interleave) and hold it until _twTick drains + unlocks. |
| 2129 |
// Idempotent per lock name, so a bulk plan's many streams lock once — and |
| 2130 |
// if a later stream's clear throws, an already-running ticker still owns |
| 2131 |
// the lock and releases it on drain. |
| 2132 |
markNonPersistent( dis ); |
| 2133 |
const clear = {}; |
| 2134 |
clear[ contentAttr ] = ''; |
| 2135 |
dis.updateBlockAttributes( id, clear ); |
| 2136 |
lockSavingForTypewriter(); |
| 2137 |
_twStreams.push( { dis, sel, id, contentAttr, tokens, i: 0, acc: '' } ); |
| 2138 |
if ( ! _twTimer ) { |
| 2139 |
_twTimer = setTimeout( _twTick, TYPEWRITER_WORD_MS ); |
| 2140 |
} |
| 2141 |
} |
| 2142 |
|
| 2143 |
// Snapshot the rich-text content of block subtrees by clientId (BEFORE an op), |
| 2144 |
// into `out`. Recurses innerBlocks so a container op also captures its children. |
| 2145 |
function snapshotContent( sel, ids, out ) { |
| 2146 |
ids.forEach( function ( cid ) { |
| 2147 |
const root = sel.getBlock( cid ); |
| 2148 |
if ( ! root ) { |
| 2149 |
return; |
| 2150 |
} |
| 2151 |
( function walk( b ) { |
| 2152 |
out[ b.clientId ] = contentOf( b ); |
| 2153 |
if ( b.innerBlocks ) { |
| 2154 |
b.innerBlocks.forEach( walk ); |
| 2155 |
} |
| 2156 |
}( root ) ); |
| 2157 |
} ); |
| 2158 |
} |
| 2159 |
|
| 2160 |
// SSOT — the ONLY place streaming is decided, AFTER an op: stream every |
| 2161 |
// rich-text block (within the op's operand subtrees OR newly minted) whose text |
| 2162 |
// CHANGED vs the pre-op snapshot, or is NEW (no snapshot). It's a pure text |
| 2163 |
// diff — op.function is NEVER inspected, so a rewrite, an insert, a replace, or |
| 2164 |
// any future operation all get the effect identically; a move / remove / style- |
| 2165 |
// only change leaves the text equal and streams nothing. |
| 2166 |
function streamChangedText( dis, sel, ids, before ) { |
| 2167 |
if ( typewriterDisabled() ) { |
| 2168 |
return; |
| 2169 |
} |
| 2170 |
const seen = {}; |
| 2171 |
ids.forEach( function ( cid ) { |
| 2172 |
const root = sel.getBlock( cid ); |
| 2173 |
if ( ! root ) { |
| 2174 |
return; |
| 2175 |
} |
| 2176 |
( function walk( b ) { |
| 2177 |
if ( seen[ b.clientId ] ) { |
| 2178 |
return; |
| 2179 |
} |
| 2180 |
seen[ b.clientId ] = true; |
| 2181 |
const cattr = firstRichTextWithContent( b ); |
| 2182 |
if ( cattr ) { |
| 2183 |
const now = String( b.attributes[ cattr ] ); |
| 2184 |
if ( now && now !== ( before[ b.clientId ] || '' ) ) { |
| 2185 |
typewriterStream( dis, sel, b.clientId, cattr, now ); |
| 2186 |
} |
| 2187 |
} |
| 2188 |
if ( b.innerBlocks ) { |
| 2189 |
b.innerBlocks.forEach( walk ); |
| 2190 |
} |
| 2191 |
}( root ) ); |
| 2192 |
} ); |
| 2193 |
} |
| 2194 |
|
| 2195 |
// Apply ONE change. Returns { new_client_ids?, result_text?, deferRebase? } |
| 2196 |
// or throws a typed Error. `isAnchor` (batch's first applied change) controls |
| 2197 |
// the streaming-text undo anchor; it's irrelevant to the non-animated kinds. |
| 2198 |
// Resolve an anchor-relative placement (insert/move "before"/"after" a |
| 2199 |
// sibling block) into the {parent, index} the dispatch API needs — using the |
| 2200 |
// LIVE store (getBlockRootClientId + getBlockIndex). This is why the MODEL no |
| 2201 |
// longer computes parent/index: it just names the neighbour and the layer that |
| 2202 |
// OWNS the tree resolves the coordinates (closing the parent:null move-to-page- |
| 2203 |
// root failure). A stale anchor throws the typed stale_client_id miss via |
| 2204 |
// liveness → failed[]. parent is '' for page root (matches getBlockRootClientId |
| 2205 |
// + the move toRoot convention). `position` is 'before' | 'after'. |
| 2206 |
function resolveAnchor( sel, anchorId, position ) { |
| 2207 |
liveness( sel, anchorId ); |
| 2208 |
return { |
| 2209 |
parent: sel.getBlockRootClientId( anchorId ) || '', |
| 2210 |
index: sel.getBlockIndex( anchorId ) + ( position === 'after' ? 1 : 0 ), |
| 2211 |
}; |
| 2212 |
} |
| 2213 |
|
| 2214 |
// Pull the anchor (before/after) off a change, if present. before/after are |
| 2215 |
// mutually exclusive (the brain superRefine enforces it); after wins only if |
| 2216 |
// both somehow arrive. |
| 2217 |
function anchorOf( c ) { |
| 2218 |
if ( c.after !== undefined ) { |
| 2219 |
return { id: c.after, position: 'after' }; |
| 2220 |
} |
| 2221 |
if ( c.before !== undefined ) { |
| 2222 |
return { id: c.before, position: 'before' }; |
| 2223 |
} |
| 2224 |
return null; |
| 2225 |
} |
| 2226 |
|
| 2227 |
// Blast radius of a DESTRUCTIVE op — the reverse edge the editor otherwise |
| 2228 |
// never computes. Returns the JS on OTHER blocks that targets an anchor inside |
| 2229 |
// the removed subtree (their `getElementById(deletedAnchor)` goes null and |
| 2230 |
// THROWS, killing that block's whole spectraCustomJS IIFE — not a clean no-op). |
| 2231 |
// SURFACED, not blocked: the model relays it to the user, and native undo / |
| 2232 |
// "don't save" restores the deleted block. Computed BEFORE the dispatch (while |
| 2233 |
// the blocks still exist) via the shared dependents SSOT. |
| 2234 |
// The removed blocks' OWN subtree (ids + all descendants), walked via |
| 2235 |
// getBlockOrder. NOT sel.getClientIdsWithDescendants( clientIds ) — that WP |
| 2236 |
// selector ignores its argument and returns EVERY page id, so `subtree` |
| 2237 |
// covered the whole page, anchorDependents excluded everything, and |
| 2238 |
// orphaned_js was ALWAYS []; the impact warning never fired in production. |
| 2239 |
function subtreeOf( sel, clientIds ) { |
| 2240 |
const out = []; |
| 2241 |
const stack = ( clientIds || [] ).slice(); |
| 2242 |
while ( stack.length ) { |
| 2243 |
const id = stack.pop(); |
| 2244 |
out.push( id ); |
| 2245 |
const kids = sel.getBlockOrder ? sel.getBlockOrder( id ) : []; |
| 2246 |
for ( let i = 0; i < kids.length; i++ ) { |
| 2247 |
stack.push( kids[ i ] ); |
| 2248 |
} |
| 2249 |
} |
| 2250 |
return out; |
| 2251 |
} |
| 2252 |
function removalImpact( sel, clientIds ) { |
| 2253 |
const u = sharedEditorUtils(); |
| 2254 |
if ( ! u || ! u.anchorDependents || ! clientIds || ! clientIds.length ) { |
| 2255 |
return undefined; |
| 2256 |
} |
| 2257 |
const subtree = subtreeOf( sel, clientIds ); |
| 2258 |
const anchors = []; |
| 2259 |
subtree.forEach( function ( id ) { |
| 2260 |
const a = sel.getBlockAttributes ? sel.getBlockAttributes( id ) : null; |
| 2261 |
if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) { |
| 2262 |
anchors.push( a.anchor ); |
| 2263 |
} |
| 2264 |
} ); |
| 2265 |
if ( ! anchors.length ) { |
| 2266 |
return undefined; |
| 2267 |
} |
| 2268 |
const orphaned = u.anchorDependents( sel, anchors, subtree ); |
| 2269 |
return orphaned.length ? { orphaned_js: orphaned } : undefined; |
| 2270 |
} |
| 2271 |
|
| 2272 |
// --- Computed-effect verification -------------------------------------- |
| 2273 |
// A className edit can be SET yet paint NOTHING — a phantom token |
| 2274 |
// (bg-neutral-900), or a gs- !important rule winning the cascade. Rather than |
| 2275 |
// classify the className, we ask the only question that matters: did the |
| 2276 |
// PAINTED style move? Snapshot the target's FULL computed style before + |
| 2277 |
// after dispatch; a setAttributes that changed the class string but moved |
| 2278 |
// ZERO computed values is a silent no-op. The brain nudges on it instead of |
| 2279 |
// reporting a false success. (No token list / regex — the rendered result is |
| 2280 |
// the source of truth; this also catches specificity loss, not just phantoms.) |
| 2281 |
function canvasCtx() { |
| 2282 |
const iframe = document.querySelector( 'iframe[name="editor-canvas"]' ); |
| 2283 |
return iframe && iframe.contentDocument |
| 2284 |
? { doc: iframe.contentDocument, win: iframe.contentWindow } |
| 2285 |
: { doc: document, win: window }; |
| 2286 |
} |
| 2287 |
function computedSnapshot( clientId ) { |
| 2288 |
const ctx = canvasCtx(); |
| 2289 |
const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' ); |
| 2290 |
if ( ! el ) { |
| 2291 |
return null; |
| 2292 |
} |
| 2293 |
const cs = ctx.win.getComputedStyle( el ); |
| 2294 |
let out = ''; |
| 2295 |
for ( let i = 0; i < cs.length; i++ ) { |
| 2296 |
out += cs[ i ] + ':' + cs.getPropertyValue( cs[ i ] ) + ';'; |
| 2297 |
} |
| 2298 |
return out; |
| 2299 |
} |
| 2300 |
// True when the computed style moved (or we can't compare — never a false noop). |
| 2301 |
function computedChanged( before, after ) { |
| 2302 |
return ! before || ! after ? true : before !== after; |
| 2303 |
} |
| 2304 |
// The GBS utility stylesheet text (~1MB). L6 — memoized per source element, |
| 2305 |
// invalidated by the parsed sheet's RULE COUNT (cheap — no string |
| 2306 |
// materialization to compare): the live JIT only ever APPENDS rules, so a |
| 2307 |
// changed count is the exact "stylesheet grew" signal. When the CSSOM sheet |
| 2308 |
// is unreadable (detached element) we fall through to a fresh read. The |
| 2309 |
// rare fallback path (no id'd element) stays unmemoized. |
| 2310 |
let _utilCssCache = null; // { el, ruleCount, css } |
| 2311 |
function utilSheetRuleCount( el ) { |
| 2312 |
try { |
| 2313 |
return el.sheet && el.sheet.cssRules ? el.sheet.cssRules.length : -1; |
| 2314 |
} catch ( e ) { |
| 2315 |
return -1; |
| 2316 |
} |
| 2317 |
} |
| 2318 |
// CSS injected by ensureLiveUtilityCss (tokens compiled server-side after a |
| 2319 |
// live insert). Appended to every readUtilityCss() result so the no-op / |
| 2320 |
// hasLiveUtility checks see the just-injected rules as real utilities. |
| 2321 |
function liveJitCssText( doc ) { |
| 2322 |
const el = doc.getElementById( 'zipwp-gbs-live-jit' ); |
| 2323 |
return el ? ( el.textContent || '' ) : ''; |
| 2324 |
} |
| 2325 |
function readUtilityCss() { |
| 2326 |
const ctx = canvasCtx(); |
| 2327 |
const el = ctx.doc.getElementById( 'spectra-gs-utility-classes-inline-css' ); |
| 2328 |
if ( el ) { |
| 2329 |
const count = utilSheetRuleCount( el ); |
| 2330 |
if ( _utilCssCache && _utilCssCache.el === el && count !== -1 && _utilCssCache.ruleCount === count ) { |
| 2331 |
return _utilCssCache.css + liveJitCssText( ctx.doc ); |
| 2332 |
} |
| 2333 |
const text = el.textContent || ''; |
| 2334 |
_utilCssCache = { el, ruleCount: count, css: text }; |
| 2335 |
return text + liveJitCssText( ctx.doc ); |
| 2336 |
} |
| 2337 |
let css = ''; |
| 2338 |
const styles = ctx.doc.querySelectorAll( 'style' ); |
| 2339 |
for ( let s = 0; s < styles.length; s++ ) { |
| 2340 |
css += styles[ s ].textContent || ''; |
| 2341 |
} |
| 2342 |
return css; |
| 2343 |
} |
| 2344 |
// A recognized GBS utility (bg-base-800, text-primary-500, p-10, …) is NOT a |
| 2345 |
// no-op even when the live editor canvas doesn't move. The editor's live JIT |
| 2346 |
// emits the utility at low specificity (`:root .token`, 0,2,0), which LOSES to |
| 2347 |
// a per-block gs- style's editor selector (`.editor-styles-wrapper |
| 2348 |
// [class*="wp-block"].gs-…`, 0,3,0) — so the canvas preview stays put. But the |
| 2349 |
// SERVER JIT re-emits the SAME utility at `:root .token×5` (0,5,0) on save + |
| 2350 |
// frontend, where it WINS and paints. So a className whose tokens are real |
| 2351 |
// utilities (their `.token` rule exists in the GBS stylesheet) WILL render on |
| 2352 |
// save; flagging it `noop` would nudge the brain into a false "couldn't style |
| 2353 |
// it" retry loop. Only a token with NO matching rule (a typo / phantom like |
| 2354 |
// bg-neutral-900) is a true no-op. Substring match (cheap; the JIT writes |
| 2355 |
// `.token{…}`); gs-/semantic classes carry no `.token` utility rule so they |
| 2356 |
// don't false-trigger. |
| 2357 |
function classNameHasLiveUtility( className, css ) { |
| 2358 |
if ( typeof className !== 'string' || className.trim() === '' || ! css ) { |
| 2359 |
return false; |
| 2360 |
} |
| 2361 |
const tokens = className.trim().split( /\s+/ ); |
| 2362 |
for ( let t = 0; t < tokens.length; t++ ) { |
| 2363 |
const tok = tokens[ t ]; |
| 2364 |
if ( ! tok || tok.indexOf( 'gs-' ) === 0 ) { |
| 2365 |
continue; |
| 2366 |
} // gs- = per-block style |
| 2367 |
// Match the token as a COMPLETE class selector, not a prefix: the JIT |
| 2368 |
// writes `.token{` / `.token:hover` / `.token,`, so the char after the |
| 2369 |
// token must be a selector boundary — a `\w`/`-` after it means the token |
| 2370 |
// is only a PREFIX of a real rule. Without this, a truncated token |
| 2371 |
// (`bg-base`) substring-matches `.bg-base-800` and a phantom class is |
| 2372 |
// exempted from the no-op check → reported as painted (silent false success). |
| 2373 |
const esc = tok.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); |
| 2374 |
if ( new RegExp( '\\.' + esc + '(?![\\w-])' ).test( css ) ) { |
| 2375 |
return true; |
| 2376 |
} |
| 2377 |
} |
| 2378 |
return false; |
| 2379 |
} |
| 2380 |
|
| 2381 |
// ── Phantom-animation detection (the live-found `animate-bounce` no-op) ───── |
| 2382 |
// An `animate-<ident>` utility compiles to a `.animate-<ident>{ animation: |
| 2383 |
// <ident> … }` RULE even when NO `@keyframes <ident>` is registered on the |
| 2384 |
// site (the JIT's animate-<ident> fallback + a Tailwind default like `bounce` |
| 2385 |
// that the GBS system never defines). So the class "applies" and |
| 2386 |
// classNameHasLiveUtility sees a rule — but with the keyframes missing the |
| 2387 |
// browser paints NOTHING. That is why a bundled `border-red-500 animate-bounce` |
| 2388 |
// reported applied (the border moved computed style) while the bounce silently |
| 2389 |
// did nothing. We detect it from the RESOLVED cascade, not the token: read the |
| 2390 |
// element's computed `animation-name` and flag any name (≠ `none`) that has no |
| 2391 |
// matching `@keyframes` rule. Resolved-name based, so it's blind to how the |
| 2392 |
// class maps to a keyframe (preset vs fallback) and never false-flags a |
| 2393 |
// preset whose keyframes DO exist. |
| 2394 |
|
| 2395 |
// Does the canvas define `@keyframes <ident>`? Read via the CSSOM |
| 2396 |
// (CSSKeyframesRule — rule type 7 — carries a `.name`), walking @media groups, |
| 2397 |
// and skipping unreadable (cross-origin) sheets. Pure over (doc, ident). |
| 2398 |
function hasKeyframesRule( doc, ident ) { |
| 2399 |
if ( ! doc || ! ident ) { |
| 2400 |
return false; |
| 2401 |
} |
| 2402 |
function walk( rules ) { |
| 2403 |
for ( let i = 0; i < rules.length; i++ ) { |
| 2404 |
const r = rules[ i ]; |
| 2405 |
// CSSRule.KEYFRAMES_RULE === 7. |
| 2406 |
if ( r && r.type === 7 && r.name === ident ) { |
| 2407 |
return true; |
| 2408 |
} |
| 2409 |
// Recurse into ANY grouping rule that carries child rules — @media |
| 2410 |
// (4) and @supports (12) plus @layer / @container, whose legacy |
| 2411 |
// numeric `type` is 0 and would otherwise be skipped, hiding |
| 2412 |
// keyframes nested inside them. (Skip type 7 — its children are the |
| 2413 |
// keyframe steps, already handled by the name check above.) |
| 2414 |
if ( r && r.type !== 7 && r.cssRules && walk( r.cssRules ) ) { |
| 2415 |
return true; |
| 2416 |
} |
| 2417 |
} |
| 2418 |
return false; |
| 2419 |
} |
| 2420 |
// Scan both regular sheets and constructable adoptedStyleSheets (Gutenberg |
| 2421 |
// / Spectra inject some canvas CSS this way; doc.styleSheets omits them). |
| 2422 |
const styleSheets = ( doc && doc.styleSheets ) || []; |
| 2423 |
const adopted = ( doc && doc.adoptedStyleSheets ) || []; |
| 2424 |
const sheets = []; |
| 2425 |
for ( let i = 0; i < styleSheets.length; i++ ) { |
| 2426 |
sheets.push( styleSheets[ i ] ); |
| 2427 |
} |
| 2428 |
for ( let i = 0; i < adopted.length; i++ ) { |
| 2429 |
sheets.push( adopted[ i ] ); |
| 2430 |
} |
| 2431 |
for ( let s = 0; s < sheets.length; s++ ) { |
| 2432 |
let rules; |
| 2433 |
try { |
| 2434 |
rules = sheets[ s ].cssRules || sheets[ s ].rules; |
| 2435 |
} catch ( e ) { |
| 2436 |
continue; |
| 2437 |
} // cross-origin |
| 2438 |
if ( rules && walk( rules ) ) { |
| 2439 |
return true; |
| 2440 |
} |
| 2441 |
} |
| 2442 |
return false; |
| 2443 |
} |
| 2444 |
|
| 2445 |
// Names in a computed `animation-name` value that have NO `@keyframes` rule → |
| 2446 |
// phantom motion (paints nothing). Pure over (animationNameValue, doc) so a |
| 2447 |
// unit test locks it. `none` and empty are ignored; duplicates collapse. |
| 2448 |
function missingKeyframeNames( animationNameValue, doc ) { |
| 2449 |
if ( typeof animationNameValue !== 'string' ) { |
| 2450 |
return []; |
| 2451 |
} |
| 2452 |
const names = animationNameValue.split( ',' ) |
| 2453 |
.map( function ( n ) { |
| 2454 |
return n.trim(); |
| 2455 |
} ) |
| 2456 |
.filter( function ( n ) { |
| 2457 |
return n && n !== 'none'; |
| 2458 |
} ); |
| 2459 |
const missing = []; |
| 2460 |
for ( let i = 0; i < names.length; i++ ) { |
| 2461 |
if ( missing.indexOf( names[ i ] ) === -1 && ! hasKeyframesRule( doc, names[ i ] ) ) { |
| 2462 |
missing.push( names[ i ] ); |
| 2463 |
} |
| 2464 |
} |
| 2465 |
return missing; |
| 2466 |
} |
| 2467 |
|
| 2468 |
// The phantom animation names actually resolved on a block (its computed |
| 2469 |
// animation-name minus any that have keyframes). '' / unreadable node → []. |
| 2470 |
function phantomAnimationsOnBlock( clientId ) { |
| 2471 |
const ctx = canvasCtx(); |
| 2472 |
const el = ctx.doc.querySelector( '[data-block="' + clientId + '"]' ); |
| 2473 |
if ( ! el ) { |
| 2474 |
return []; |
| 2475 |
} |
| 2476 |
const cs = ctx.win.getComputedStyle( el ); |
| 2477 |
return missingKeyframeNames( cs.getPropertyValue( 'animation-name' ), ctx.doc ); |
| 2478 |
} |
| 2479 |
|
| 2480 |
// boostUtilitySpecificity + utilityRuleBody REMOVED — their own stated |
| 2481 |
// REMOVAL CONDITION ("once editor-side utilities reach parity with the |
| 2482 |
// server JIT") is met by ensureLiveUtilityCss below, which injects the |
| 2483 |
// server compiler's OWN output (×5 base + media-wrapped responsive |
| 2484 |
// variants) for every token a plan touches. The boost was also the root |
| 2485 |
// cause of the stacked-grid-until-reload bug: it re-emitted BASE tokens |
| 2486 |
// (e.g. grid-cols-1) at ×5 specificity WITHOUT their media context, so a |
| 2487 |
// boosted base beat its md:/lg: variant siblings at every viewport width |
| 2488 |
// until a reload dropped the session-scoped boost element. Any lingering |
| 2489 |
// `zipwp-gbs-editor-boost` element from an older session dies on reload. |
| 2490 |
|
| 2491 |
// ── LIVE-JIT for tokens the canvas has never seen ───────────────────────── |
| 2492 |
// ROOT CAUSE this closes: the per-post JIT stylesheet is compiled from SAVED |
| 2493 |
// content at editor load (spectra-blocks Engine::enqueue_jit_for_current_post |
| 2494 |
// → JitCache::get_for_post). Blocks inserted programmatically AFTER load |
| 2495 |
// (Vibe Editing section inserts) can carry utility tokens with NO rule |
| 2496 |
// anywhere in the canvas — they render unstyled (grids stack) until the next |
| 2497 |
// save + reload. Fix at the source of truth: ask Spectra's OWN compiler |
| 2498 |
// (REST /spectra-blocks/v1/global-styles/jit-compile — the same JitCompiler |
| 2499 |
// that runs at save) for the missing tokens' CSS and inject it into the |
| 2500 |
// canvas BEFORE the settle/no-op check reads computed styles, so the live |
| 2501 |
// preview and the verification both converge to saved-page truth instantly. |
| 2502 |
// Session-deduped per token; network-tolerant (the rpc reply must never |
| 2503 |
// hang on this — hard 1500ms cap, failure = canvas converges on save). |
| 2504 |
const _liveJitRequested = {}; // token → 1 (session-scoped request dedupe) |
| 2505 |
function collectPlanClassNames( ops ) { |
| 2506 |
const out = []; |
| 2507 |
function fromBlocks( blocks ) { |
| 2508 |
if ( ! blocks ) { |
| 2509 |
return; |
| 2510 |
} |
| 2511 |
for ( let b = 0; b < blocks.length; b++ ) { |
| 2512 |
const blk = blocks[ b ]; |
| 2513 |
if ( ! blk || typeof blk !== 'object' ) { |
| 2514 |
continue; |
| 2515 |
} |
| 2516 |
const cn = blk.attributes && blk.attributes.className; |
| 2517 |
if ( typeof cn === 'string' && cn.trim() !== '' ) { |
| 2518 |
out.push( cn ); |
| 2519 |
} |
| 2520 |
fromBlocks( blk.innerBlocks ); |
| 2521 |
} |
| 2522 |
} |
| 2523 |
for ( let i = 0; i < ops.length; i++ ) { |
| 2524 |
const op = ops[ i ] || {}; |
| 2525 |
if ( op.attributes && typeof op.attributes.className === 'string' ) { |
| 2526 |
out.push( op.attributes.className ); |
| 2527 |
} |
| 2528 |
fromBlocks( op.blocks ); |
| 2529 |
} |
| 2530 |
return out; |
| 2531 |
} |
| 2532 |
function ensureLiveUtilityCss( classNames ) { |
| 2533 |
try { |
| 2534 |
const ctx = canvasCtx(); |
| 2535 |
// Compile EVERY touched token (session-deduped) — not only the ones |
| 2536 |
// with no live rule. The live/static sheets carry tokens at MIXED |
| 2537 |
// specificities (static 0,2,0; persisted dynamic ×5), and partial |
| 2538 |
// injection breaks the responsive cascade: a base token already |
| 2539 |
// present at ×5 (e.g. grid-cols-1) beats a freshly-injected |
| 2540 |
// md:/lg: variant unless the variant arrives at the SAME server |
| 2541 |
// parity alongside it. Server output is the SSOT cascade — inject |
| 2542 |
// it wholesale. |
| 2543 |
const missing = []; |
| 2544 |
for ( let c = 0; c < classNames.length; c++ ) { |
| 2545 |
const toks = String( classNames[ c ] ).trim().split( /\s+/ ); |
| 2546 |
for ( let t = 0; t < toks.length; t++ ) { |
| 2547 |
const tok = toks[ t ]; |
| 2548 |
if ( ! tok || tok.indexOf( 'gs-' ) === 0 || _liveJitRequested[ tok ] ) { |
| 2549 |
continue; |
| 2550 |
} |
| 2551 |
_liveJitRequested[ tok ] = 1; |
| 2552 |
missing.push( tok ); |
| 2553 |
} |
| 2554 |
} |
| 2555 |
if ( ! missing.length ) { |
| 2556 |
return Promise.resolve(); |
| 2557 |
} |
| 2558 |
const apiFetch = window.wp && window.wp.apiFetch; |
| 2559 |
if ( ! apiFetch ) { |
| 2560 |
return Promise.resolve(); |
| 2561 |
} |
| 2562 |
const fetchP = apiFetch( { |
| 2563 |
path: '/spectra-blocks/v1/global-styles/jit-compile', |
| 2564 |
method: 'POST', |
| 2565 |
data: { class_strings: [ missing.join( ' ' ) ] }, |
| 2566 |
} ).then( function ( res ) { |
| 2567 |
const css = res && typeof res.css === 'string' ? res.css : ''; |
| 2568 |
if ( ! css ) { |
| 2569 |
return; |
| 2570 |
} |
| 2571 |
let styleEl = ctx.doc.getElementById( 'zipwp-gbs-live-jit' ); |
| 2572 |
if ( ! styleEl ) { |
| 2573 |
styleEl = ctx.doc.createElement( 'style' ); |
| 2574 |
styleEl.id = 'zipwp-gbs-live-jit'; |
| 2575 |
( ctx.doc.head || ctx.doc.documentElement ).appendChild( styleEl ); |
| 2576 |
} |
| 2577 |
styleEl.textContent = ( styleEl.textContent || '' ) + '\n' + css; |
| 2578 |
} ).catch( function () { |
| 2579 |
// Un-mark on FAILURE so a later plan retries. Marking at collection |
| 2580 |
// time is what dedupes a burst inside one plan, but keeping the mark |
| 2581 |
// after a failed request meant one transient REST error (or a site |
| 2582 |
// whose spectra-blocks predates the jit-compile route, where EVERY |
| 2583 |
// request 404s) left those tokens uncompiled for the whole editor |
| 2584 |
// session. Two user-visible consequences, not one: the section renders |
| 2585 |
// unstyled until save+reload, AND classNameHasLiveUtility then reads a |
| 2586 |
// correctly-applied className as a no-op — so the paint check reports a |
| 2587 |
// false no-op and the brain nudges the model to "fix" working styling. |
| 2588 |
// Not un-marked on the 1500ms cap: that request is still in flight and |
| 2589 |
// will inject when it lands, so a retry there would just duplicate it. |
| 2590 |
for ( let m = 0; m < missing.length; m++ ) { |
| 2591 |
delete _liveJitRequested[ missing[ m ] ]; |
| 2592 |
} |
| 2593 |
} ); |
| 2594 |
const capP = new Promise( function ( resolve ) { |
| 2595 |
setTimeout( resolve, 1500 ); |
| 2596 |
} ); |
| 2597 |
return Promise.race( [ fetchP, capP ] ); |
| 2598 |
} catch ( e ) { |
| 2599 |
return Promise.resolve(); |
| 2600 |
} |
| 2601 |
} |
| 2602 |
|
| 2603 |
// ── Persist a generated section's GBS class bodies (DEFERRED to Save) ───── |
| 2604 |
// A generate_section insert carries `args.styles` (section-scoped schema-v1 |
| 2605 |
// buckets: classes/wrapperStyles/mediaQuery) authored with the site's SEMANTIC |
| 2606 |
// tokens (var(--primary)/var(--heading)/…). ensureLiveUtilityCss above cannot |
| 2607 |
// help — it SKIPS gs-* tokens because the JIT only compiles the utility ramp. |
| 2608 |
// These custom class bodies do NOT ride the block className, so they need the |
| 2609 |
// page GBS store — but writing it NOW would persist to the DB before the user |
| 2610 |
// Saves (breaking the "nothing hits the DB until Save" contract and orphaning |
| 2611 |
// CSS if they discard). So we QUEUE it (shared.queueSectionGbsForSave): it |
| 2612 |
// RENDERS + injects the CSS for a live PREVIEW immediately (a pure compile — no |
| 2613 |
// DB write) and flushes the payload to the store only when the editor completes |
| 2614 |
// a real Save. Best-effort: a preview failure logs and the section converges on |
| 2615 |
// Save; it NEVER fails the insert. |
| 2616 |
function persistSectionStyles( styles, postId ) { |
| 2617 |
try { |
| 2618 |
const apiFetch = window.wp && window.wp.apiFetch; |
| 2619 |
const u = sharedEditorUtils(); |
| 2620 |
if ( ! apiFetch || ! u || ! u.queueSectionGbsForSave ) { |
| 2621 |
return Promise.resolve(); |
| 2622 |
} |
| 2623 |
const persistP = u.queueSectionGbsForSave( apiFetch, styles, postId ).catch( function ( e ) { |
| 2624 |
// eslint-disable-next-line no-console -- developer signal; the section still inserts + persists on Save |
| 2625 |
console.warn( '[ZIP AI:apply-change] section GBS preview failed — section may render unstyled until Save', e ); |
| 2626 |
} ); |
| 2627 |
// The preview render folds into the reply-blocking liveCssReady below. A |
| 2628 |
// REST *rejection* is caught above, but a stalled/hung socket never |
| 2629 |
// settles, which would hold the apply_change reply until the brain's RPC |
| 2630 |
// timeout (the false-timeout -> duplicate-content failure the settle |
| 2631 |
// tuning avoids). Cap it the same way ensureLiveUtilityCss caps its fetch |
| 2632 |
// so the reply always fires; the paint still converges on Save. |
| 2633 |
const capP = new Promise( function ( resolve ) { |
| 2634 |
setTimeout( resolve, 1500 ); |
| 2635 |
} ); |
| 2636 |
return Promise.race( [ persistP, capP ] ); |
| 2637 |
} catch ( e ) { |
| 2638 |
return Promise.resolve(); |
| 2639 |
} |
| 2640 |
} |
| 2641 |
|
| 2642 |
// ── Serialized Gutenberg execution plan ({ version, operations[] }). One |
| 2643 |
// operation = ONE real wp.data.dispatch('core/block-editor') function + named |
| 2644 |
// args, keyed on op.function. Each op reuses the shared guards |
| 2645 |
// (createBlock/placeContent/partitionAttrs/stripBannedAttrs/resolveAnchor/ |
| 2646 |
// liveness/deepMerge). The brain zod already coerced grid + ran the GBS |
| 2647 |
// denylist; we re-run them (defense-in-depth) so a direct/forked caller can't |
| 2648 |
// crash the canvas or paint banned props. |
| 2649 |
|
| 2650 |
function coerceGridLayoutLengthAttrs( attrs ) { |
| 2651 |
if ( ! isPlainObject( attrs ) ) { |
| 2652 |
return attrs; |
| 2653 |
} |
| 2654 |
function coerce( layout ) { |
| 2655 |
if ( ! isPlainObject( layout ) || typeof layout.minimumColumnWidth !== 'number' ) { |
| 2656 |
return layout; |
| 2657 |
} |
| 2658 |
const c = {}; |
| 2659 |
for ( const k in layout ) { |
| 2660 |
if ( Object.prototype.hasOwnProperty.call( layout, k ) ) { |
| 2661 |
c[ k ] = layout[ k ]; |
| 2662 |
} |
| 2663 |
} |
| 2664 |
c.minimumColumnWidth = layout.minimumColumnWidth + 'px'; |
| 2665 |
return c; |
| 2666 |
} |
| 2667 |
const out = {}; |
| 2668 |
for ( const k in attrs ) { |
| 2669 |
if ( Object.prototype.hasOwnProperty.call( attrs, k ) ) { |
| 2670 |
out[ k ] = attrs[ k ]; |
| 2671 |
} |
| 2672 |
} |
| 2673 |
if ( 'layout' in out ) { |
| 2674 |
out.layout = coerce( out.layout ); |
| 2675 |
} |
| 2676 |
if ( isPlainObject( out.responsiveControls ) ) { |
| 2677 |
const rc = {}; |
| 2678 |
for ( const bp in out.responsiveControls ) { |
| 2679 |
if ( ! Object.prototype.hasOwnProperty.call( out.responsiveControls, bp ) ) { |
| 2680 |
continue; |
| 2681 |
} |
| 2682 |
const v = out.responsiveControls[ bp ]; |
| 2683 |
rc[ bp ] = ( isPlainObject( v ) && 'layout' in v ) |
| 2684 |
? Object.assign( {}, v, { layout: coerce( v.layout ) } ) |
| 2685 |
: v; |
| 2686 |
} |
| 2687 |
out.responsiveControls = rc; |
| 2688 |
} |
| 2689 |
return out; |
| 2690 |
} |
| 2691 |
|
| 2692 |
// Image-attribute routing: a source tool (find-assets / import-media / |
| 2693 |
// ai_generate_image) yields { id, url } and the agent sets a UNIFORM |
| 2694 |
// { url, id, alt } on the target block. Map that to the block's REAL image |
| 2695 |
// attribute so it lands regardless of block type — most importantly a Spectra |
| 2696 |
// container BACKGROUND, which lives in `background:{ type:'image', media:{ |
| 2697 |
// id, url, type:'image' } }` (NOT a bare `url`, and NOT the `overlay*` keys — |
| 2698 |
// those are a separate tint ON TOP of the background). This mirrors exactly |
| 2699 |
// what Spectra's own Background control writes (components/background onSelect). |
| 2700 |
// Keyed off the block's REGISTERED schema (not a hardcoded name): a native |
| 2701 |
// image block (has `url`) keeps {url,id,alt}; a block exposing a `background` |
| 2702 |
// object gets the background-media shape. deepMerge at apply time preserves |
| 2703 |
// the container's other background keys (position/size/repeat). Conservative: |
| 2704 |
// only fires on a bare image url/id with no agent-supplied `background` object. |
| 2705 |
// Pull an { id, url } image ref out of a candidate object (any of the |
| 2706 |
// url/id-bearing shapes weak models emit). |
| 2707 |
function pickImageRef( o ) { |
| 2708 |
if ( ! isPlainObject( o ) ) { |
| 2709 |
return null; |
| 2710 |
} |
| 2711 |
const url = typeof o.url === 'string' && o.url !== '' ? o.url : undefined; |
| 2712 |
const id = typeof o.id === 'number' |
| 2713 |
? o.id |
| 2714 |
: ( typeof o.id === 'string' && o.id !== '' ? o.id : undefined ); |
| 2715 |
return url !== undefined || id !== undefined ? { url, id } : null; |
| 2716 |
} |
| 2717 |
|
| 2718 |
// Find the image the agent intends, from EITHER a bare { url, id } (the |
| 2719 |
// contract) OR a mis-built `background` object — models routinely invent the |
| 2720 |
// old UAGB shape (`background.backgroundImageDesktop`) or guess `media`/`image` |
| 2721 |
// sub-keys. We extract the ref from any of them so the SHAPE the model used |
| 2722 |
// can't break the write. Overlay keys are intentionally NOT consulted — an |
| 2723 |
// overlay is a separate tint, not the background. |
| 2724 |
function extractImageRef( attrs ) { |
| 2725 |
const bare = pickImageRef( attrs ); |
| 2726 |
if ( bare ) { |
| 2727 |
return bare; |
| 2728 |
} |
| 2729 |
const bg = attrs.background; |
| 2730 |
if ( isPlainObject( bg ) ) { |
| 2731 |
const keys = [ 'media', 'backgroundImageDesktop', 'image', 'imageDesktop', 'backgroundImage' ]; |
| 2732 |
for ( let i = 0; i < keys.length; i++ ) { |
| 2733 |
const ref = pickImageRef( bg[ keys[ i ] ] ); |
| 2734 |
if ( ref ) { |
| 2735 |
return ref; |
| 2736 |
} |
| 2737 |
} |
| 2738 |
} |
| 2739 |
return null; |
| 2740 |
} |
| 2741 |
|
| 2742 |
function normalizeImageAttrs( name, attrs, currentAttrs ) { |
| 2743 |
if ( ! isPlainObject( attrs ) ) { |
| 2744 |
return attrs; |
| 2745 |
} |
| 2746 |
const registered = registeredAttrKeysOf( name ); |
| 2747 |
if ( registered === null ) { |
| 2748 |
return attrs; |
| 2749 |
} // unknown block — degrade open |
| 2750 |
// Native image block (core/image, spectra/image) takes {url,id,alt} as-is. |
| 2751 |
if ( registered.indexOf( 'url' ) !== -1 ) { |
| 2752 |
return attrs; |
| 2753 |
} |
| 2754 |
// Background-capable container (Spectra): the bg image lives in |
| 2755 |
// background.{type:'image', media:{id,url,type:'image'}} — mirror exactly |
| 2756 |
// what Spectra's Background control writes. We REBUILD it from the |
| 2757 |
// extracted ref (ignoring the model's invented sub-keys), so any input |
| 2758 |
// shape lands. deepMerge at apply preserves the block's other background |
| 2759 |
// keys (position/size/repeat). |
| 2760 |
if ( registered.indexOf( 'background' ) === -1 ) { |
| 2761 |
return attrs; |
| 2762 |
} |
| 2763 |
const ref = extractImageRef( attrs ); |
| 2764 |
if ( ref === null ) { |
| 2765 |
return attrs; |
| 2766 |
} // no image intent (pure style/overlay edit) |
| 2767 |
const out = {}; |
| 2768 |
Object.keys( attrs ).forEach( function ( k ) { |
| 2769 |
if ( k !== 'url' && k !== 'id' && k !== 'alt' && k !== 'background' ) { |
| 2770 |
out[ k ] = attrs[ k ]; |
| 2771 |
} |
| 2772 |
} ); |
| 2773 |
function freshMedia() { |
| 2774 |
const m = { type: 'image' }; |
| 2775 |
if ( ref.id !== undefined ) { |
| 2776 |
m.id = ref.id; |
| 2777 |
} |
| 2778 |
if ( ref.url !== undefined ) { |
| 2779 |
m.url = ref.url; |
| 2780 |
} |
| 2781 |
return m; |
| 2782 |
} |
| 2783 |
out.background = { type: 'image', media: freshMedia() }; |
| 2784 |
// A Spectra container ALSO holds per-breakpoint background overrides under |
| 2785 |
// responsiveControls.{lg,md,sm}.background. The renderer reads the active |
| 2786 |
// device's override FIRST, so a base-only update leaves the old image |
| 2787 |
// showing at any breakpoint that has its own image background. Mirror the |
| 2788 |
// new image into every responsive breakpoint that CURRENTLY has an image |
| 2789 |
// background (don't create one where there isn't, and don't touch a |
| 2790 |
// color/gradient/video override). deepMerge at apply preserves each |
| 2791 |
// breakpoint's other keys (layout/height). |
| 2792 |
const rc = currentAttrs && currentAttrs.responsiveControls; |
| 2793 |
if ( isPlainObject( rc ) ) { |
| 2794 |
// Merge the mirror ON TOP of any responsiveControls the agent set in |
| 2795 |
// THIS op (out already carries it from the copy above) — replacing it |
| 2796 |
// wholesale would drop a same-op RC edit (e.g. responsiveControls.md.layout). |
| 2797 |
const rcOut = isPlainObject( out.responsiveControls ) ? Object.assign( {}, out.responsiveControls ) : {}; |
| 2798 |
let mirrored = false; |
| 2799 |
Object.keys( rc ).forEach( function ( device ) { |
| 2800 |
const dbg = rc[ device ] && rc[ device ].background; |
| 2801 |
if ( isPlainObject( dbg ) && dbg.type === 'image' ) { |
| 2802 |
// Per-device shallow merge: keep the agent's other per-device |
| 2803 |
// keys (layout/height) for this breakpoint, swap the background. |
| 2804 |
rcOut[ device ] = Object.assign( {}, rcOut[ device ], { |
| 2805 |
background: { type: 'image', media: freshMedia() }, |
| 2806 |
} ); |
| 2807 |
mirrored = true; |
| 2808 |
} |
| 2809 |
} ); |
| 2810 |
if ( mirrored ) { |
| 2811 |
out.responsiveControls = rcOut; |
| 2812 |
} |
| 2813 |
} |
| 2814 |
return out; |
| 2815 |
} |
| 2816 |
|
| 2817 |
// Normalize a bare attributes object for updateBlockAttributes: GBS strip + |
| 2818 |
// grid coerce + content routing + image routing + registry partition |
| 2819 |
// (collects unknown_attrs). `currentAttrs` (the live block's attributes) lets |
| 2820 |
// image routing mirror a container background into its responsive overrides. |
| 2821 |
function normalizePlanAttrs( name, attributes, sink, currentAttrs ) { |
| 2822 |
let a = stripBannedAttrs( attributes || {} ); |
| 2823 |
a = coerceGridLayoutLengthAttrs( a ); |
| 2824 |
a = placeContent( name, a ); |
| 2825 |
a = normalizeImageAttrs( name, a, currentAttrs ); |
| 2826 |
const parts = partitionAttrs( name, a ); |
| 2827 |
if ( sink ) { |
| 2828 |
Array.prototype.push.apply( sink, parts.unknown ); |
| 2829 |
} |
| 2830 |
return parts.valid; |
| 2831 |
} |
| 2832 |
|
| 2833 |
// Sentinel destination meaning "keep each target in its OWN current parent" |
| 2834 |
// (a pure re-order). Used when the plan carried an `index` but no explicit |
| 2835 |
// `toRootClientId`: '' is the PAGE ROOT in Gutenberg, so defaulting an omitted |
| 2836 |
// destination to '' silently RELOCATED a nested block out to the top level. |
| 2837 |
// The brain now rejects that plan shape outright; this keeps an older or |
| 2838 |
// hand-built envelope from doing damage here. |
| 2839 |
const KEEP_CURRENT_PARENT = { __keepCurrentParent: true }; |
| 2840 |
|
| 2841 |
// Move targets grouped by source root (multi-parent safe; preserves input order |
| 2842 |
// at the destination by advancing insertAt per group). Shared shape with the |
| 2843 |
// 5-kind move case. `toRoot` may be KEEP_CURRENT_PARENT, in which case each |
| 2844 |
// group moves within its own parent. |
| 2845 |
function moveTargetsGrouped( sel, dis, targets, toRoot, insertAt ) { |
| 2846 |
const groups = {}; |
| 2847 |
const groupOrder = []; |
| 2848 |
( targets || [] ).forEach( function ( id ) { |
| 2849 |
const fromRoot = sel.getBlockRootClientId( id ) || ''; |
| 2850 |
if ( ! Object.prototype.hasOwnProperty.call( groups, fromRoot ) ) { |
| 2851 |
groups[ fromRoot ] = []; |
| 2852 |
groupOrder.push( fromRoot ); |
| 2853 |
} |
| 2854 |
groups[ fromRoot ].push( id ); |
| 2855 |
} ); |
| 2856 |
let moveIdx = 0; |
| 2857 |
groupOrder.forEach( function ( fromRoot ) { |
| 2858 |
if ( moveIdx > 0 && typeof dis.__unstableMarkNextChangeAsNotPersistent === 'function' ) { |
| 2859 |
dis.__unstableMarkNextChangeAsNotPersistent(); |
| 2860 |
} |
| 2861 |
moveIdx++; |
| 2862 |
const grp = groups[ fromRoot ]; |
| 2863 |
const dest = toRoot === KEEP_CURRENT_PARENT ? fromRoot : toRoot; |
| 2864 |
dis.moveBlocksToPosition( grp, fromRoot, dest, insertAt ); |
| 2865 |
// Advance ONLY when every group lands in the same container. The |
| 2866 |
// accumulator exists to preserve input order at a SHARED destination; |
| 2867 |
// under KEEP_CURRENT_PARENT each group lands in its own parent, so |
| 2868 |
// advancing measures group 2's index against a container group 1 never |
| 2869 |
// touched ({clientIds:['a','b'], index:0} across two parents put `b` at |
| 2870 |
// index 1 of its own parent instead of the requested 0). An OMITTED |
| 2871 |
// index must also stay omitted — `undefined + n` is NaN, which |
| 2872 |
// dispatches a garbage position for every group after the first (that |
| 2873 |
// half predates the sentinel and hit any multi-parent move that carried |
| 2874 |
// no explicit index). |
| 2875 |
if ( toRoot !== KEEP_CURRENT_PARENT && insertAt !== undefined ) { |
| 2876 |
insertAt += grp.length; |
| 2877 |
} |
| 2878 |
} ); |
| 2879 |
} |
| 2880 |
|
| 2881 |
// Parse an op's AUTHORITATIVE resolved section markup, if it carries any. |
| 2882 |
// |
| 2883 |
// `section_markup` is present when the brain materialized the section against |
| 2884 |
// the live site — real SureForms/SureDonation ids and re-hosted image urls. The |
| 2885 |
// `op.blocks` array is the PRE-resolution snapshot (formId:0, remote urls) and |
| 2886 |
// is stale once that happened, so whenever markup is present it WINS. |
| 2887 |
// |
| 2888 |
// Shared by insertBlocks and replaceInnerBlocks. It used to be inline in the |
| 2889 |
// insert case only, which is why placing a form-bearing section as a REVAMP |
| 2890 |
// shipped an unconfigured placeholder form while the identical section ADDED |
| 2891 |
// came out working. |
| 2892 |
// |
| 2893 |
// Returns an array of blocks, or undefined to fall back to the op.blocks path. |
| 2894 |
// Throws only for an unregistered block type — a real, typed op failure. |
| 2895 |
function parseResolvedSectionMarkup( op ) { |
| 2896 |
const markup = typeof op.section_markup === 'string' ? op.section_markup.trim() : ''; |
| 2897 |
if ( markup === '' || ! window.wp.blocks || typeof window.wp.blocks.parse !== 'function' ) { |
| 2898 |
return undefined; |
| 2899 |
} |
| 2900 |
let blocks; |
| 2901 |
// A parse throw is very unlikely (wp.blocks.parse is tolerant), but if it |
| 2902 |
// does, DON'T fail the op — fall back to the op.blocks path. |
| 2903 |
try { |
| 2904 |
blocks = window.wp.blocks.parse( markup ).filter( function ( b ) { |
| 2905 |
return b && b.name; |
| 2906 |
} ); |
| 2907 |
} catch ( e ) { |
| 2908 |
return undefined; |
| 2909 |
} |
| 2910 |
if ( ! Array.isArray( blocks ) || ! blocks.length ) { |
| 2911 |
return undefined; |
| 2912 |
} |
| 2913 |
// F1: wp.blocks.parse maps an UNREGISTERED name to core/missing (which HAS a |
| 2914 |
// name, so it survived the filter) — the unregistered_block_type gate toBlock() |
| 2915 |
// enforces on the block path is bypassed here. Fail typed-and-loud instead of |
| 2916 |
// planting an "Unsupported block" placeholder. Outside the try so it is a real |
| 2917 |
// op failure, not swallowed into the fallback. |
| 2918 |
const missing = blocks.filter( function ( b ) { |
| 2919 |
return b.name === 'core/missing'; |
| 2920 |
} ); |
| 2921 |
if ( missing.length ) { |
| 2922 |
const orig = missing[ 0 ].attributes && missing[ 0 ].attributes.originalName; |
| 2923 |
throw new Error( |
| 2924 |
'unregistered_block_type: "' + ( orig || 'unknown' ) + |
| 2925 |
'" is not registered on this site — the section cannot be placed.' |
| 2926 |
); |
| 2927 |
} |
| 2928 |
// F2: the markup path bypasses toBlock → stripBannedAttrs. Reapply it here |
| 2929 |
// (symmetric with the block path, S4/PR#282) so a spectra/* block in resolved |
| 2930 |
// markup can't paint a GBS-banned visual attr onto the page + GBS store. |
| 2931 |
blocks.forEach( stripBannedAttrsDeep ); |
| 2932 |
return blocks; |
| 2933 |
} |
| 2934 |
|
| 2935 |
// Apply ONE operation. Returns { new_client_ids?, result_text?, unknown_attrs? } |
| 2936 |
// or throws a typed Error (→ failed[]). The allowlist gate (defense-in-depth; |
| 2937 |
// the brain zod already gated) is the default case. |
| 2938 |
function runOp( sel, dis, op ) { |
| 2939 |
// Block-safety pre-check for mutating ops (locked / synced / template). |
| 2940 |
const mutKind = MUTATION_KIND[ op.function ]; |
| 2941 |
if ( mutKind ) { |
| 2942 |
// eslint-disable-next-line eqeqeq |
| 2943 |
const mutTargets = op.clientIds || ( op.rootClientId != null ? [ op.rootClientId ] : [] ); |
| 2944 |
mutTargets.forEach( function ( id ) { |
| 2945 |
assertMutable( sel, id, mutKind ); |
| 2946 |
} ); |
| 2947 |
} |
| 2948 |
switch ( op.function ) { |
| 2949 |
case 'updateBlockAttributes': { |
| 2950 |
const ids = op.clientIds || []; |
| 2951 |
ids.forEach( function ( id ) { |
| 2952 |
liveness( sel, id ); |
| 2953 |
} ); |
| 2954 |
const sinkU = []; |
| 2955 |
// Normalize + fail-closed pre-checks for ALL targets BEFORE any |
| 2956 |
// dispatch, so a rejected write (e.g. a content flatten) fails the |
| 2957 |
// whole op with NO partial apply — the model gets a clean typed |
| 2958 |
// error, not a half-applied bulk edit. |
| 2959 |
const planned = ids.map( function ( id ) { |
| 2960 |
const blk = sel.getBlock( id ); |
| 2961 |
const norm = normalizePlanAttrs( blk.name, op.attributes, sinkU, blk.attributes ); |
| 2962 |
// FAIL CLOSED — a plain-text content write must not silently |
| 2963 |
// strip the block's inline formatting (the "make it 4/5" defect). |
| 2964 |
assertNoContentFlatten( blk, norm ); |
| 2965 |
// gs-* identity is immutable on a className write (anti-clobber): |
| 2966 |
// keep the block's own gs- tokens, apply only the model's utilities. |
| 2967 |
if ( typeof norm.className === 'string' ) { |
| 2968 |
norm.className = reconcileClassName( |
| 2969 |
blk.attributes && blk.attributes.className, |
| 2970 |
norm.className, |
| 2971 |
); |
| 2972 |
} |
| 2973 |
return { id, blk, norm }; |
| 2974 |
} ); |
| 2975 |
// Apply the final attrs (persistent). The typewriter flourish is |
| 2976 |
// added uniformly afterwards by the runPlan before/after text diff |
| 2977 |
// (streamChangedText, SSOT) — never per-op here. |
| 2978 |
planned.forEach( function ( p, j ) { |
| 2979 |
// L2 — coalesce a BULK restyle into one undo level: only the |
| 2980 |
// first target's dispatch is persistent (whether THAT one is |
| 2981 |
// persistent is the caller's op-level decision); the rest |
| 2982 |
// merge into it, matching reorderChildren/moveTargetsGrouped. |
| 2983 |
if ( j > 0 ) { |
| 2984 |
markNonPersistent( dis ); |
| 2985 |
} |
| 2986 |
dis.updateBlockAttributes( p.id, deepMerge( p.blk.attributes || {}, p.norm ) ); |
| 2987 |
} ); |
| 2988 |
return sinkU.length ? { unknown_attrs: sinkU } : {}; |
| 2989 |
} |
| 2990 |
case 'insertBlocks': { |
| 2991 |
const insAnchor = anchorOf( op ); |
| 2992 |
let insRoot = op.rootClientId; |
| 2993 |
let insIndex = op.index; |
| 2994 |
if ( insAnchor ) { |
| 2995 |
const ra = resolveAnchor( sel, insAnchor.id, insAnchor.position ); |
| 2996 |
insRoot = ra.parent === '' ? null : ra.parent; |
| 2997 |
insIndex = ra.index; |
| 2998 |
// eslint-disable-next-line eqeqeq |
| 2999 |
} else if ( op.rootClientId != null ) { |
| 3000 |
liveness( sel, op.rootClientId ); |
| 3001 |
} |
| 3002 |
assertInsertable( sel, insRoot ); // GBR-2 — destination container lock |
| 3003 |
const sinkI = []; |
| 3004 |
// Resolved markup WINS when present (see parseResolvedSectionMarkup). |
| 3005 |
// assertEffectiveContent is skipped on that path — it aligns specs to |
| 3006 |
// blocks by index, and the parsed blocks don't index-align with the |
| 3007 |
// stale op.blocks specs. |
| 3008 |
let insBlocks = parseResolvedSectionMarkup( op ); |
| 3009 |
if ( ! insBlocks || insBlocks.length === 0 ) { |
| 3010 |
// No resolved markup (plain section, no bound site, or it parsed |
| 3011 |
// to nothing) → the proven block-object path, unchanged. |
| 3012 |
insBlocks = ( op.blocks || [] ).map( function ( s ) { |
| 3013 |
return toBlock( s, sinkI ); |
| 3014 |
} ); |
| 3015 |
assertEffectiveContent( op.blocks, insBlocks ); |
| 3016 |
} |
| 3017 |
// eslint-disable-next-line eqeqeq |
| 3018 |
dis.insertBlocks( insBlocks, insIndex, insRoot == null ? undefined : insRoot ); |
| 3019 |
return { |
| 3020 |
new_client_ids: insBlocks.map( function ( b ) { |
| 3021 |
return b.clientId; |
| 3022 |
} ), |
| 3023 |
result_text: insBlocks.map( renderedTextOf ), |
| 3024 |
unknown_attrs: sinkI.length ? sinkI : undefined, |
| 3025 |
}; |
| 3026 |
} |
| 3027 |
case 'removeBlocks': { |
| 3028 |
( op.clientIds || [] ).forEach( function ( id ) { |
| 3029 |
liveness( sel, id ); |
| 3030 |
} ); |
| 3031 |
const rmImpact = removalImpact( sel, op.clientIds ); |
| 3032 |
dis.removeBlocks( op.clientIds ); |
| 3033 |
return rmImpact ? { impact: rmImpact } : {}; |
| 3034 |
} |
| 3035 |
case 'moveBlocksToPosition': { |
| 3036 |
if ( op.order ) { |
| 3037 |
reorderChildren( sel, dis, op.toRootClientId, op.order ); return {}; |
| 3038 |
} |
| 3039 |
( op.clientIds || [] ).forEach( function ( id ) { |
| 3040 |
liveness( sel, id ); |
| 3041 |
} ); |
| 3042 |
const mvAnchor = anchorOf( op ); |
| 3043 |
let toRoot, insertAt; |
| 3044 |
if ( mvAnchor ) { |
| 3045 |
const rm = resolveAnchor( sel, mvAnchor.id, mvAnchor.position ); |
| 3046 |
toRoot = rm.parent; |
| 3047 |
insertAt = rm.index; |
| 3048 |
} else { |
| 3049 |
// Distinguish an EXPLICIT page-root destination (null) from an |
| 3050 |
// OMITTED one (undefined). Collapsing both to '' meant a plan |
| 3051 |
// carrying only `{clientIds, index}` relocated nested blocks to |
| 3052 |
// the PAGE ROOT — a silent, destructive un-nesting. An omitted |
| 3053 |
// destination now means "re-order within the current parent". |
| 3054 |
if ( op.toRootClientId === null ) { |
| 3055 |
toRoot = ''; |
| 3056 |
} else if ( op.toRootClientId === undefined ) { |
| 3057 |
toRoot = KEEP_CURRENT_PARENT; |
| 3058 |
} else { |
| 3059 |
toRoot = op.toRootClientId; |
| 3060 |
} |
| 3061 |
insertAt = op.index; |
| 3062 |
} |
| 3063 |
// M2 — the destination container must accept a move-in |
| 3064 |
// (canMoveBlock above only folds in the SOURCE parent's lock). |
| 3065 |
// A keep-current-parent move never crosses a container boundary, so |
| 3066 |
// there is no new destination lock to check. |
| 3067 |
if ( toRoot !== KEEP_CURRENT_PARENT ) { |
| 3068 |
assertMoveDestination( sel, toRoot ); |
| 3069 |
} |
| 3070 |
moveTargetsGrouped( sel, dis, op.clientIds, toRoot, insertAt ); |
| 3071 |
return {}; |
| 3072 |
} |
| 3073 |
case 'replaceBlocks': { |
| 3074 |
( op.clientIds || [] ).forEach( function ( id ) { |
| 3075 |
liveness( sel, id ); |
| 3076 |
} ); |
| 3077 |
const sinkR = []; |
| 3078 |
const repBlocks = ( op.blocks || [] ).map( function ( s ) { |
| 3079 |
return toBlock( s, sinkR ); |
| 3080 |
} ); |
| 3081 |
assertEffectiveContent( op.blocks, repBlocks ); |
| 3082 |
const repImpact = removalImpact( sel, op.clientIds ); |
| 3083 |
dis.replaceBlocks( op.clientIds, repBlocks ); |
| 3084 |
return { |
| 3085 |
new_client_ids: repBlocks.map( function ( b ) { |
| 3086 |
return b.clientId; |
| 3087 |
} ), |
| 3088 |
result_text: repBlocks.map( renderedTextOf ), |
| 3089 |
unknown_attrs: sinkR.length ? sinkR : undefined, |
| 3090 |
impact: repImpact, |
| 3091 |
}; |
| 3092 |
} |
| 3093 |
case 'replaceInnerBlocks': { |
| 3094 |
liveness( sel, op.rootClientId ); |
| 3095 |
// Structurally this ADDS/REMOVES the container's children — the |
| 3096 |
// same class of change insertBlocks/duplicateBlocks need GBR-2 |
| 3097 |
// for (assertMutable's 'edit' kind above only covers editing the |
| 3098 |
// container's OWN attributes, not manipulating its children). |
| 3099 |
// Previously missing here, so a templateLock:'all'/'insert' |
| 3100 |
// container's content could be wiped via this op alone. |
| 3101 |
assertInsertable( sel, op.rootClientId ); |
| 3102 |
// The OLD children are removed by this op — surface any JS elsewhere |
| 3103 |
// that targets an anchor inside them (computed before the swap). |
| 3104 |
const innerRemovedImpact = removalImpact( sel, sel.getBlockOrder ? sel.getBlockOrder( op.rootClientId ) : [] ); |
| 3105 |
const sinkN = []; |
| 3106 |
// Resolved markup WINS here too. Without this, placing a section as a |
| 3107 |
// REVAMP shipped an unconfigured placeholder form while the identical |
| 3108 |
// section ADDED came out working — the same content, two outcomes, |
| 3109 |
// decided only by which op the model happened to pick. |
| 3110 |
let innerBlocks = parseResolvedSectionMarkup( op ); |
| 3111 |
if ( ! innerBlocks || innerBlocks.length === 0 ) { |
| 3112 |
innerBlocks = ( op.blocks || [] ).map( function ( s ) { |
| 3113 |
return toBlock( s, sinkN ); |
| 3114 |
} ); |
| 3115 |
// Same empty-content guard insertBlocks/replaceBlocks apply — a |
| 3116 |
// spec that asked for text but built an empty block must fail |
| 3117 |
// loud, not silently succeed. No-ops when op.blocks is legitimately |
| 3118 |
// [] (clearing the container is the intentional, allowed case). |
| 3119 |
// Skipped on the markup path for the same index-alignment reason |
| 3120 |
// as insertBlocks. |
| 3121 |
assertEffectiveContent( op.blocks, innerBlocks ); |
| 3122 |
} |
| 3123 |
dis.replaceInnerBlocks( op.rootClientId, innerBlocks ); |
| 3124 |
return { |
| 3125 |
new_client_ids: innerBlocks.map( function ( b ) { |
| 3126 |
return b.clientId; |
| 3127 |
} ), |
| 3128 |
result_text: innerBlocks.map( renderedTextOf ), |
| 3129 |
unknown_attrs: sinkN.length ? sinkN : undefined, |
| 3130 |
impact: innerRemovedImpact, |
| 3131 |
}; |
| 3132 |
} |
| 3133 |
case 'duplicateBlocks': { |
| 3134 |
// Group by ORIGINAL parent, like moveTargetsGrouped already does for |
| 3135 |
// moveBlocksToPosition — native duplicateBlocks assumes a single- |
| 3136 |
// parent selection (mirrors real UI multi-select, which can only |
| 3137 |
// span one parent), so cross-parent clientIds in ONE call misplace |
| 3138 |
// later duplicates into the FIRST group's parent (found live |
| 3139 |
// 2026-07-14: duplicating a tab's trigger+panel together landed the |
| 3140 |
// panel copy inside the trigger row, not beside its sibling panels). |
| 3141 |
// Dispatching per root — one native call per parent — keeps each |
| 3142 |
// duplicate next to its own original. |
| 3143 |
// |
| 3144 |
// Snapshotting each root's child order BEFORE its dispatch also |
| 3145 |
// recovers the NEW clientIds (Gutenberg mints them internally — we |
| 3146 |
// never see them from the dispatch call itself) by diffing after. |
| 3147 |
// Without this, a "duplicate this, then customize the copy" plan |
| 3148 |
// (e.g. add a tab) has no way to target the copy in a follow-up op. |
| 3149 |
const groups = {}; |
| 3150 |
const groupOrder = []; |
| 3151 |
// Drop a selected block whose ANCESTOR is also selected — duplicating |
| 3152 |
// the ancestor already copies it, so keeping it mints a redundant copy. |
| 3153 |
const selectedIds = op.clientIds || []; |
| 3154 |
const topLevelIds = selectedIds.filter( function ( id ) { |
| 3155 |
const parents = typeof sel.getBlockParents === 'function' ? sel.getBlockParents( id ) || [] : []; |
| 3156 |
return ! parents.some( function ( p ) { |
| 3157 |
return selectedIds.indexOf( p ) !== -1; |
| 3158 |
} ); |
| 3159 |
} ); |
| 3160 |
topLevelIds.forEach( function ( id ) { |
| 3161 |
liveness( sel, id ); |
| 3162 |
const root = sel.getBlockRootClientId( id ) || ''; |
| 3163 |
// GBR-2 — a duplicate lands as a sibling, so the PARENT container |
| 3164 |
// must allow insertion (template-locked parent → refuse). |
| 3165 |
assertInsertable( sel, root ); |
| 3166 |
if ( ! Object.prototype.hasOwnProperty.call( groups, root ) ) { |
| 3167 |
groups[ root ] = { ids: [], before: sel.getBlockOrder( root ).slice() }; |
| 3168 |
groupOrder.push( root ); |
| 3169 |
} |
| 3170 |
groups[ root ].ids.push( id ); |
| 3171 |
} ); |
| 3172 |
const newIds = []; |
| 3173 |
groupOrder.forEach( function ( root, g ) { |
| 3174 |
if ( g > 0 ) { |
| 3175 |
markNonPersistent( dis ); |
| 3176 |
} // one ⌘Z for the whole plan |
| 3177 |
dis.duplicateBlocks( groups[ root ].ids ); |
| 3178 |
const before = groups[ root ].before; |
| 3179 |
sel.getBlockOrder( root ).forEach( function ( id ) { |
| 3180 |
if ( before.indexOf( id ) === -1 ) { |
| 3181 |
newIds.push( id ); |
| 3182 |
} |
| 3183 |
} ); |
| 3184 |
} ); |
| 3185 |
return { new_client_ids: newIds }; |
| 3186 |
} |
| 3187 |
case 'selectBlock': { |
| 3188 |
liveness( sel, op.clientId ); |
| 3189 |
dis.selectBlock( op.clientId ); |
| 3190 |
return {}; |
| 3191 |
} |
| 3192 |
default: |
| 3193 |
throw new Error( 'forbidden_function:' + op.function ); |
| 3194 |
} |
| 3195 |
} |
| 3196 |
|
| 3197 |
// A style op whose computed paint we verify (updateBlockAttributes w/ className). |
| 3198 |
function isPlanStyleOp( op ) { |
| 3199 |
return op && op.function === 'updateBlockAttributes' && |
| 3200 |
op.attributes && typeof op.attributes.className === 'string' && |
| 3201 |
op.attributes.className.trim() !== '' && |
| 3202 |
Array.isArray( op.clientIds ) && op.clientIds.length > 0; |
| 3203 |
} |
| 3204 |
|
| 3205 |
// [RTRACE] browser-side round-trip tracer. Always pushes structured entries |
| 3206 |
// into window.__zipwpTrace (read via agent-browser eval); the console mirror is |
| 3207 |
// GATED behind window.ZIPAI_CONFIG.debug so it doesn't spam every shopper's |
| 3208 |
// devtools in production (PR #282 S5). Diagnostic only — wrapped so it can |
| 3209 |
// never break an apply. |
| 3210 |
function rtrace( hop, data ) { |
| 3211 |
try { |
| 3212 |
const entry = Object.assign( { hop, ts: Date.now() }, data || {} ); |
| 3213 |
( window.__zipwpTrace = window.__zipwpTrace || [] ).push( entry ); |
| 3214 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 3215 |
console.log( '[RTRACE] gutenberg ' + hop, entry ); |
| 3216 |
} |
| 3217 |
} catch ( e ) { /* never break the apply on a trace failure */ } |
| 3218 |
} |
| 3219 |
|
| 3220 |
// F4 — STRUCTURAL-effect verdict for a minting op. insertBlocks / |
| 3221 |
// duplicateBlocks / replaceInnerBlocks all MINT clientIds; the op only |
| 3222 |
// truly LANDED if those minted blocks are actually in the tree now. A |
| 3223 |
// dispatch that produced an applied entry but whose minted ids never |
| 3224 |
// appeared (target detached, coalesced away, silent drop) changed nothing — |
| 3225 |
// the "Added a sixth card" false success. Non-minting ops |
| 3226 |
// (updateBlockAttributes / move / remove) are not this verdict's concern and |
| 3227 |
// return true (they carry their own noop/unverifiable verdicts). `getBlock` |
| 3228 |
// is the live tree probe, bound by the caller. Pure + exported so a unit |
| 3229 |
// test locks the REAL logic against a mock sel. |
| 3230 |
function structurallyLanded( op, entry, getBlock ) { |
| 3231 |
const fn = op && op.function; |
| 3232 |
if ( fn !== 'insertBlocks' && fn !== 'duplicateBlocks' && fn !== 'replaceInnerBlocks' ) { |
| 3233 |
return true; |
| 3234 |
} |
| 3235 |
const ids = ( entry && entry.new_client_ids ) || []; |
| 3236 |
// Every id the op DID mint must be live in the tree — a minted-but-absent |
| 3237 |
// id is the silent drop we're guarding against (the "sixth card" bug). |
| 3238 |
if ( ! ids.every( function ( id ) { |
| 3239 |
return !! getBlock( id ); |
| 3240 |
} ) ) { |
| 3241 |
return false; |
| 3242 |
} |
| 3243 |
// insertBlocks / duplicateBlocks ALWAYS add ≥1 block — zero minted means |
| 3244 |
// nothing was added. replaceInnerBlocks may legitimately mint zero |
| 3245 |
// (clearing a container's children is a valid structural change; a |
| 3246 |
// non-empty blocks[] always mints, so empty here ⇒ intended clear), so |
| 3247 |
// it is NOT required to mint. |
| 3248 |
if ( ( fn === 'insertBlocks' || fn === 'duplicateBlocks' ) && ids.length === 0 ) { |
| 3249 |
return false; |
| 3250 |
} |
| 3251 |
return true; |
| 3252 |
} |
| 3253 |
|
| 3254 |
function runPlan( args ) { |
| 3255 |
if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) { |
| 3256 |
return { success: false, error: 'block_editor_unavailable' }; |
| 3257 |
} |
| 3258 |
const sel = window.wp.data.select( 'core/block-editor' ); |
| 3259 |
const dis = window.wp.data.dispatch( 'core/block-editor' ); |
| 3260 |
if ( ! sel || ! dis ) { |
| 3261 |
return { success: false, error: 'block_editor_unavailable' }; |
| 3262 |
} |
| 3263 |
|
| 3264 |
const livePostId = currentPostId(); |
| 3265 |
const expectedPostId = args && args.post_id; |
| 3266 |
// SEC-2 — FAIL CLOSED. This guard is the SOLE protection against applying a |
| 3267 |
// plan to the WRONG open post (a second editor tab). If the brain stamped an |
| 3268 |
// expected post_id but the live post id can't be resolved, we cannot verify |
| 3269 |
// we are on the right post — refuse rather than apply blind. (Previously this |
| 3270 |
// fell open when livePostId was null, skipping the check entirely.) |
| 3271 |
// eslint-disable-next-line eqeqeq |
| 3272 |
if ( expectedPostId != null && livePostId == null ) { |
| 3273 |
return { |
| 3274 |
success: true, |
| 3275 |
data: { |
| 3276 |
post_id: null, applied: [], failed: [], |
| 3277 |
refused: { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null' }, |
| 3278 |
}, |
| 3279 |
}; |
| 3280 |
} |
| 3281 |
// eslint-disable-next-line eqeqeq |
| 3282 |
if ( expectedPostId != null && livePostId != null && Number( livePostId ) !== Number( expectedPostId ) ) { |
| 3283 |
return { |
| 3284 |
success: true, |
| 3285 |
data: { |
| 3286 |
post_id: livePostId, applied: [], failed: [], |
| 3287 |
refused: { reason: 'post_id_mismatch', detail: 'expected=' + expectedPostId + ',live=' + livePostId }, |
| 3288 |
}, |
| 3289 |
}; |
| 3290 |
} |
| 3291 |
|
| 3292 |
// Subtree scope-lock — enforce ONLY when the brain bound one AND the |
| 3293 |
// locked container is still live this turn. A stale lock (selection |
| 3294 |
// changed / block gone) degrades OPEN — never a false block. |
| 3295 |
let scopeLock = args && typeof args.scope_lock === 'string' && args.scope_lock !== '' |
| 3296 |
? args.scope_lock |
| 3297 |
: null; |
| 3298 |
if ( scopeLock && ! sel.getBlock( scopeLock ) ) { |
| 3299 |
scopeLock = null; |
| 3300 |
} |
| 3301 |
|
| 3302 |
const ops = ( args && args.operations ) || []; |
| 3303 |
const applied = []; |
| 3304 |
const failed = []; |
| 3305 |
const beforeFx = {}; |
| 3306 |
for ( let b = 0; b < ops.length; b++ ) { |
| 3307 |
// EXEC-2: snapshot EVERY target of a (possibly bulk) restyle, not just |
| 3308 |
// clientIds[0] — else idB/idC painting nothing slips through as success. |
| 3309 |
if ( isPlanStyleOp( ops[ b ] ) ) { |
| 3310 |
beforeFx[ b ] = ops[ b ].clientIds.map( function ( id ) { |
| 3311 |
return computedSnapshot( id ); |
| 3312 |
} ); |
| 3313 |
} |
| 3314 |
} |
| 3315 |
|
| 3316 |
// [RTRACE] EXEC #1 — the whole plan the brain handed to live Gutenberg. |
| 3317 |
rtrace( 'runPlan:entry', { |
| 3318 |
post_id: args && args.post_id, version: args && args.version, |
| 3319 |
livePostId, op_count: ops.length, |
| 3320 |
functions: ops.map( function ( o ) { |
| 3321 |
return o && o.function; |
| 3322 |
} ), |
| 3323 |
} ); |
| 3324 |
|
| 3325 |
for ( let i = 0; i < ops.length; i++ ) { |
| 3326 |
try { |
| 3327 |
const op = ops[ i ] || {}; |
| 3328 |
// Subtree confinement: refuse a mutating op that reaches OUTSIDE |
| 3329 |
// the selected container (→ failed[]); in-scope ops still apply. |
| 3330 |
if ( scopeLock ) { |
| 3331 |
assertWithinScope( sel, op, scopeLock ); |
| 3332 |
} |
| 3333 |
// [RTRACE] EXEC #2 — the exact wp.data.dispatch('core/block-editor') call about |
| 3334 |
// to fire, with its resolved args + the LIVE target block(s) it will hit. |
| 3335 |
// eslint-disable-next-line eqeqeq |
| 3336 |
const __tids = op.clientIds || ( op.clientId != null ? [ op.clientId ] : ( op.rootClientId != null ? [ op.rootClientId ] : [] ) ); |
| 3337 |
rtrace( 'runOp:dispatch', { |
| 3338 |
index: i, fn: op.function, |
| 3339 |
dispatch: "wp.data.dispatch('core/block-editor')." + op.function + '(...)', |
| 3340 |
clientIds: op.clientIds, clientId: op.clientId, |
| 3341 |
rootClientId: op.rootClientId, toRootClientId: op.toRootClientId, |
| 3342 |
index_arg: op.index, order: op.order, before: op.before, after: op.after, |
| 3343 |
blocks: ( op.blocks || [] ).map( function ( s ) { |
| 3344 |
return s && s.name; |
| 3345 |
} ), |
| 3346 |
attr_keys: op.attributes ? Object.keys( op.attributes ) : undefined, |
| 3347 |
className: op.attributes && op.attributes.className, |
| 3348 |
targets: __tids.map( function ( id ) { |
| 3349 |
const blk = sel.getBlock( id ); |
| 3350 |
return { clientId: id, live: !! blk, name: blk && blk.name }; |
| 3351 |
} ), |
| 3352 |
} ); |
| 3353 |
if ( applied.length > 0 ) { |
| 3354 |
markNonPersistent( dis ); |
| 3355 |
} // undo coalescing → one ⌘Z |
| 3356 |
// SSOT streaming: snapshot operand text BEFORE the op, then stream |
| 3357 |
// whatever text CHANGED or APPEARED after — a pure diff, no check on |
| 3358 |
// op.function. Works for any operation, present or future. |
| 3359 |
const _before = {}; |
| 3360 |
snapshotContent( sel, opOperandIds( op ), _before ); |
| 3361 |
const res = runOp( sel, dis, op ); |
| 3362 |
streamChangedText( |
| 3363 |
dis, sel, |
| 3364 |
opOperandIds( op ).concat( ( res && res.new_client_ids ) || [] ), |
| 3365 |
_before |
| 3366 |
); |
| 3367 |
const entry = { index: i }; |
| 3368 |
if ( res && res.new_client_ids && res.new_client_ids.length ) { |
| 3369 |
entry.new_client_ids = res.new_client_ids; |
| 3370 |
} |
| 3371 |
if ( res && res.result_text ) { |
| 3372 |
entry.result_text = res.result_text; |
| 3373 |
} |
| 3374 |
if ( res && res.unknown_attrs && res.unknown_attrs.length ) { |
| 3375 |
entry.unknown_attrs = res.unknown_attrs; |
| 3376 |
} |
| 3377 |
if ( res && res.impact ) { |
| 3378 |
entry.impact = res.impact; |
| 3379 |
} |
| 3380 |
applied.push( entry ); |
| 3381 |
// [RTRACE] EXEC #3 — what the dispatch returned (minted clientIds / rendered text / dropped attrs). |
| 3382 |
rtrace( 'runOp:result', { |
| 3383 |
index: i, fn: op.function, |
| 3384 |
new_client_ids: res && res.new_client_ids, result_text: res && res.result_text, |
| 3385 |
unknown_attrs: res && res.unknown_attrs, |
| 3386 |
} ); |
| 3387 |
} catch ( e ) { |
| 3388 |
failed.push( { index: i, error: ( e && e.message ) || 'apply_failed' } ); |
| 3389 |
// [RTRACE] EXEC #3b — typed failure (stale_client_id / empty_content / forbidden_function). |
| 3390 |
rtrace( 'runOp:failed', { index: i, fn: ( ops[ i ] || {} ).function, error: ( e && e.message ) || 'apply_failed' } ); |
| 3391 |
} |
| 3392 |
} |
| 3393 |
|
| 3394 |
// LIVE-JIT: compile + inject CSS for utility tokens this plan introduced |
| 3395 |
// (inserted blocks included — the className collector below only covers |
| 3396 |
// updateBlockAttributes). Kicked off NOW so the network round-trip runs |
| 3397 |
// in parallel with the 120ms paint settle; the settle body awaits it so |
| 3398 |
// the no-op check reads computed styles WITH the new rules present. |
| 3399 |
const utilCssReady = ensureLiveUtilityCss( collectPlanClassNames( ops ) ); |
| 3400 |
|
| 3401 |
// Auto-focus: honour an explicit trailing selectBlock (model-driven), else leave selection. |
| 3402 |
// eslint-disable-next-line eqeqeq |
| 3403 |
const postId = livePostId != null ? livePostId : expectedPostId; |
| 3404 |
// Fold the generated-section GBS persist into the readiness promise so the |
| 3405 |
// paint settle below waits for the class bodies to land before it reads |
| 3406 |
// computed styles. Only when the brain attached section styles AND a valid |
| 3407 |
// post id resolved (a real post id is a positive int → truthy). |
| 3408 |
const sectionStyles = args && args.styles; |
| 3409 |
const gbsReady = sectionStyles && postId |
| 3410 |
? persistSectionStyles( sectionStyles, postId ) |
| 3411 |
: Promise.resolve(); |
| 3412 |
const liveCssReady = Promise.all( [ utilCssReady, gbsReady ] ); |
| 3413 |
return new Promise( function ( resolve ) { |
| 3414 |
// M5 — TWO-PHASE settle. The 120ms snapshot is a heuristic: a slow |
| 3415 |
// re-render (large page, busy main thread) can leave the computed |
| 3416 |
// style untouched at 120ms and repaint later — a FALSE noop that |
| 3417 |
// nudges the brain into a pointless restyle retry. So a noop is only |
| 3418 |
// CONFIRMED by a second unchanged reading 160ms later; ops whose |
| 3419 |
// style already moved at phase 1 (the common case) pay nothing extra |
| 3420 |
// and the reply leaves at 120ms exactly as before. |
| 3421 |
function finishReply( classNames ) { |
| 3422 |
// F4 — stamp landed:false on any minting op whose blocks aren't in |
| 3423 |
// the tree now (see structurallyLanded). Runs in BOTH settle paths |
| 3424 |
// (fast + phase-2) because it lives here. Only sets the flag when |
| 3425 |
// NOT landed — an absent flag means landed (brain degrades-open). |
| 3426 |
for ( let s = 0; s < applied.length; s++ ) { |
| 3427 |
const landed = structurallyLanded( |
| 3428 |
ops[ applied[ s ].index ], |
| 3429 |
applied[ s ], |
| 3430 |
function ( id ) { |
| 3431 |
return sel.getBlock( id ); |
| 3432 |
} |
| 3433 |
); |
| 3434 |
if ( ! landed ) { |
| 3435 |
applied[ s ].landed = false; |
| 3436 |
} |
| 3437 |
// Phantom motion: a className op whose block resolves an |
| 3438 |
// `animation-name` with NO `@keyframes` (e.g. `animate-bounce` |
| 3439 |
// with no `bounce` keyframes). The class applied but paints no |
| 3440 |
// motion — surface it so the brain never reports the animation |
| 3441 |
// as working (it stays applied; this is advisory, not a failure). |
| 3442 |
// Gate on the op actually INTRODUCING an `animate-*` token so a |
| 3443 |
// plain edit (e.g. `border-red-500`) on a block already carrying |
| 3444 |
// a theme/AOS animation can't mis-attribute that pre-existing |
| 3445 |
// animation as this op's phantom. |
| 3446 |
const op = ops[ applied[ s ].index ]; |
| 3447 |
if ( op && op.function === 'updateBlockAttributes' && |
| 3448 |
op.attributes && typeof op.attributes.className === 'string' && |
| 3449 |
/(^|\s)animate-/.test( op.attributes.className ) && |
| 3450 |
Array.isArray( op.clientIds ) ) { |
| 3451 |
const phantoms = []; |
| 3452 |
for ( let t = 0; t < op.clientIds.length; t++ ) { |
| 3453 |
phantomAnimationsOnBlock( op.clientIds[ t ] ).forEach( function ( name ) { |
| 3454 |
if ( phantoms.indexOf( name ) === -1 ) { |
| 3455 |
phantoms.push( name ); |
| 3456 |
} |
| 3457 |
} ); |
| 3458 |
} |
| 3459 |
if ( phantoms.length ) { |
| 3460 |
applied[ s ].phantom_animations = phantoms; |
| 3461 |
} |
| 3462 |
} |
| 3463 |
} |
| 3464 |
// [RTRACE] EXEC #4 — the reply Gutenberg sends back (after the |
| 3465 |
// paint-settle no-op check — 120ms, +160ms confirm when needed). |
| 3466 |
// This is what the bridge POSTs to /agent/rpc-reply → brain folds it. |
| 3467 |
rtrace( 'runPlan:reply', { |
| 3468 |
post_id: postId, applied_count: applied.length, failed_count: failed.length, |
| 3469 |
noop_indices: applied.filter( function ( a ) { |
| 3470 |
return a.noop; |
| 3471 |
} ).map( function ( a ) { |
| 3472 |
return a.index; |
| 3473 |
} ), |
| 3474 |
boosted_classNames: classNames, |
| 3475 |
applied: JSON.parse( JSON.stringify( applied ) ), |
| 3476 |
failed: JSON.parse( JSON.stringify( failed ) ), |
| 3477 |
} ); |
| 3478 |
resolve( { success: true, data: { post_id: postId, applied, failed } } ); |
| 3479 |
} |
| 3480 |
setTimeout( function () { |
| 3481 |
liveCssReady.then( function () { |
| 3482 |
const classNames = []; |
| 3483 |
for ( let a = 0; a < applied.length; a++ ) { |
| 3484 |
const o = ops[ applied[ a ].index ]; |
| 3485 |
if ( o && o.function === 'updateBlockAttributes' && o.attributes && typeof o.attributes.className === 'string' ) { |
| 3486 |
classNames.push( o.attributes.className ); |
| 3487 |
} |
| 3488 |
} |
| 3489 |
// boostUtilitySpecificity REMOVED (its own stated REMOVAL |
| 3490 |
// CONDITION is met): ensureLiveUtilityCss now injects server- |
| 3491 |
// parity rules (×5 base + media-wrapped variants) for every |
| 3492 |
// touched token. The boost actively BROKE responsive variants: |
| 3493 |
// it re-emitted base tokens (grid-cols-1) at ×5 UNCONDITIONALLY, |
| 3494 |
// which beat md:/lg: siblings at every width until reload. |
| 3495 |
const utilCss = readUtilityCss(); |
| 3496 |
// Phase 1 — collect noop CANDIDATES (unchanged at 120ms). |
| 3497 |
// candidates[i] = { k (applied index), targets: clientIds still unchanged } |
| 3498 |
const candidates = []; |
| 3499 |
for ( let k = 0; k < applied.length; k++ ) { |
| 3500 |
const idx = applied[ k ].index; |
| 3501 |
if ( Object.prototype.hasOwnProperty.call( beforeFx, idx ) ) { |
| 3502 |
const o2 = ops[ idx ]; |
| 3503 |
// The className applies to EVERY target in the op; if it is a |
| 3504 |
// recognized live utility it renders post-save → never a no-op. |
| 3505 |
const hasLiveUtil = classNameHasLiveUtility( o2.attributes && o2.attributes.className, utilCss ); |
| 3506 |
if ( hasLiveUtil ) { |
| 3507 |
continue; |
| 3508 |
} |
| 3509 |
const beforeArr = beforeFx[ idx ]; |
| 3510 |
for ( let t = 0; t < o2.clientIds.length; t++ ) { |
| 3511 |
const before = beforeArr[ t ]; |
| 3512 |
const after = computedSnapshot( o2.clientIds[ t ] ); |
| 3513 |
// F3 — can't read the canvas node (null before/after): the |
| 3514 |
// paint is UNVERIFIABLE, not confirmed. Flag the op so the |
| 3515 |
// brain treats it as unconfirmed (verify, don't narrate done) |
| 3516 |
// instead of silently counting an unreadable op as painted. |
| 3517 |
if ( before === null || after === null ) { |
| 3518 |
applied[ k ].unverifiable = true; |
| 3519 |
// Report WHICH block we tried to measure, under a key of |
| 3520 |
// its OWN. One shared `client_id` carried two verdicts |
| 3521 |
// that can disagree: an op with an unreadable target A |
| 3522 |
// and an unchanged target B ends up `unverifiable:true` |
| 3523 |
// with `client_id:'B'` once phase 2 stamps the noop id — |
| 3524 |
// naming a block that WAS verifiably measured as the |
| 3525 |
// unverifiable one. First unreadable target wins so the |
| 3526 |
// value is deterministic rather than last-write. |
| 3527 |
if ( applied[ k ].unverifiable_client_id === undefined ) { |
| 3528 |
applied[ k ].unverifiable_client_id = o2.clientIds[ t ]; |
| 3529 |
} |
| 3530 |
continue; |
| 3531 |
} |
| 3532 |
// EXEC-2: ANY target that painted nothing marks the op a no-op. |
| 3533 |
if ( before === after ) { |
| 3534 |
candidates.push( { k, id: o2.clientIds[ t ], before } ); |
| 3535 |
break; // one unchanged target is enough to re-check this op |
| 3536 |
} |
| 3537 |
} |
| 3538 |
} |
| 3539 |
} |
| 3540 |
if ( candidates.length === 0 ) { |
| 3541 |
finishReply( classNames ); return; |
| 3542 |
} |
| 3543 |
// Phase 2 — confirm: still unchanged 160ms later = a real noop; |
| 3544 |
// moved = a late paint, NOT a noop. |
| 3545 |
setTimeout( function () { |
| 3546 |
for ( let c = 0; c < candidates.length; c++ ) { |
| 3547 |
if ( ! computedChanged( candidates[ c ].before, computedSnapshot( candidates[ c ].id ) ) ) { |
| 3548 |
applied[ candidates[ c ].k ].noop = true; |
| 3549 |
// The block whose paint we actually measured — the brain |
| 3550 |
// keys its no-op tracking by this, so a later corrective |
| 3551 |
// edit to the SAME block clears it. |
| 3552 |
applied[ candidates[ c ].k ].client_id = candidates[ c ].id; |
| 3553 |
} |
| 3554 |
} |
| 3555 |
finishReply( classNames ); |
| 3556 |
}, 160 ); |
| 3557 |
} ); |
| 3558 |
}, 120 ); |
| 3559 |
} ); |
| 3560 |
} |
| 3561 |
|
| 3562 |
function handleApplyChange( args ) { |
| 3563 |
// Vibe Editing v2: every apply_change is a serialized execution plan |
| 3564 |
// ({ post_id?, operations[] }). runPlan validates the live editor, enforces |
| 3565 |
// the post_id two-tab guard, applies each operation in order, and returns |
| 3566 |
// per-index applied[] / failed[] with computed-effect no-op detection. There |
| 3567 |
// is no other shape — the brain zod emits operations[] only. |
| 3568 |
return runPlan( args ); |
| 3569 |
} |
| 3570 |
|
| 3571 |
// Register once the bridge is ready (same retry pattern as get-context). |
| 3572 |
function initHandler() { |
| 3573 |
if ( window.zipwpMcp && window.zipwpMcp.registerTool ) { |
| 3574 |
window.zipwpMcp.registerTool( |
| 3575 |
'editor/apply-change', |
| 3576 |
async function ( args ) { |
| 3577 |
return handleApplyChange( args ); |
| 3578 |
}, |
| 3579 |
{ previewMode: 'client' } |
| 3580 |
); |
| 3581 |
} else { |
| 3582 |
setTimeout( initHandler, 100 ); |
| 3583 |
} |
| 3584 |
} |
| 3585 |
|
| 3586 |
initHandler(); |
| 3587 |
|
| 3588 |
// Test-only surface (Node/CommonJS). The browser bundle has no `module`, so |
| 3589 |
// this block is inert there and the IIFE registration path above is untouched. |
| 3590 |
// Exposes the PURE functions so unit tests exercise the REAL logic (no |
| 3591 |
// reimplementation) against a mocked window.wp.data / window.wp.blocks. |
| 3592 |
if ( typeof module !== 'undefined' && module.exports ) { |
| 3593 |
module.exports = { |
| 3594 |
handleApplyChange, |
| 3595 |
runPlan, |
| 3596 |
runOp, |
| 3597 |
normalizePlanAttrs, |
| 3598 |
coerceGridLayoutLengthAttrs, |
| 3599 |
placeContent, |
| 3600 |
renderedTextOf, |
| 3601 |
assertEffectiveContent, |
| 3602 |
// Fail-closed content-flatten invariant — a plain-text write must not |
| 3603 |
// silently strip a block's inline formatting. Exported PURE so a unit |
| 3604 |
// test locks the predicate, not a reimplementation. |
| 3605 |
contentWouldFlatten, |
| 3606 |
deepMerge, |
| 3607 |
// gs-* identity is immutable on a className write (anti-clobber). |
| 3608 |
// Exported PURE so a unit test locks the reconcile rule. |
| 3609 |
reconcileClassName, |
| 3610 |
toBlock, |
| 3611 |
// Computed-effect no-op decision logic — the highest-risk path |
| 3612 |
// (gs-utility specificity inversion has regressed before). Exported |
| 3613 |
// PURE so unit tests lock the real logic, not a reimplementation. |
| 3614 |
computedChanged, |
| 3615 |
classNameHasLiveUtility, |
| 3616 |
// Phantom-animation detection (`animate-bounce` with no keyframes). |
| 3617 |
// Exported PURE so unit tests lock the resolved-name → missing-keyframes |
| 3618 |
// logic without a live canvas. |
| 3619 |
hasKeyframesRule, |
| 3620 |
missingKeyframeNames, |
| 3621 |
stripBannedAttrs, |
| 3622 |
// Registry-driven attr validation — getBlockType(name).attributes is |
| 3623 |
// the SSOT for attr validity (mirrors what Gutenberg accepts). Exported |
| 3624 |
// PURE so unit tests lock the partition, not a reimplementation. |
| 3625 |
partitionAttrs, |
| 3626 |
registeredAttrKeysOf, |
| 3627 |
// Image-attribute routing — maps the agent's uniform {url,id,alt} to a |
| 3628 |
// block's real image attr (incl. container background overlay). Exported |
| 3629 |
// PURE so unit tests lock the routing per block type. |
| 3630 |
normalizeImageAttrs, |
| 3631 |
// Subtree scope-lock (selection confinement) — the tree-aware |
| 3632 |
// airtight half of "stay inside the selected element". Exported PURE |
| 3633 |
// so unit tests lock the in/out-of-scope decision against a mock sel. |
| 3634 |
isWithinScope, |
| 3635 |
scopeGoverningIds, |
| 3636 |
resolvedDestinationOf, |
| 3637 |
assertWithinScope, |
| 3638 |
// F4 structural-effect verdict — a minting op only landed if its |
| 3639 |
// minted clientIds are in the tree. Exported PURE so a unit test |
| 3640 |
// locks the real "did it actually add a block?" logic. |
| 3641 |
structurallyLanded, |
| 3642 |
}; |
| 3643 |
} |
| 3644 |
}() ); |
| 3645 |
|
| 3646 |
|
| 3647 |
/** |
| 3648 |
* ZipWP MCP — Vibe Editing v2: editor/get-context handler. |
| 3649 |
* |
| 3650 |
* Reads a slice of the LIVE Gutenberg block tree from wp.data and returns it as |
| 3651 |
* structured JSON. Dispatched by the brain's AgentBrowserLoop as a `js_rpc` |
| 3652 |
* tool (same eager-execute path as js_hook); the result is POSTed back to the |
| 3653 |
* Laravel /agent/rpc-reply route so the loop resolves it IN THE SAME TURN. |
| 3654 |
* |
| 3655 |
* Input: |
| 3656 |
* scope (optional) — a block clientId. Present → return that block's subtree; |
| 3657 |
* absent → the page's top-level sections (a cheap outline the brain can then |
| 3658 |
* expand by re-calling with a section clientId). |
| 3659 |
* |
| 3660 |
* Returns { success, data: { scope, postId, blocks: [row] } } where each row is |
| 3661 |
* { clientId, blockName, text, className, path, html?, computed? } — a |
| 3662 |
* DESIGN-FAITHFUL view: `text` identifies the block (stripped), `html` is the |
| 3663 |
* rich content WITH inline markup (the design pattern, when present), `computed` |
| 3664 |
* is the rendered-style digest (fontSize/color/background/padding/fontWeight, |
| 3665 |
* best-effort) so the brain edits relative to RENDERED reality, not class |
| 3666 |
* strings. The clientId is the LIVE targeting handle (re-snapshotted every |
| 3667 |
* call); the brain never persists it across turns. |
| 3668 |
* |
| 3669 |
* @package |
| 3670 |
*/ |
| 3671 |
( function () { |
| 3672 |
'use strict'; |
| 3673 |
|
| 3674 |
// Content-read budget. A get_context row must carry the block's REAL copy so |
| 3675 |
// the editor LLM rewrites what is actually on the page — the old 120/600 caps |
| 3676 |
// meant a long paragraph came back as ~120 chars and the model invented the |
| 3677 |
// rest (generic, off-page output). Sized to hold any single real block whole |
| 3678 |
// (~1300 words); only paid on an explicit scoped read, and a section's |
| 3679 |
// containers carry little own-text so the outline map stays lean. |
| 3680 |
const CONTENT_READ_MAX = 8000; |
| 3681 |
|
| 3682 |
// Trim to the budget, but make any clip VISIBLE — a silent truncation is the |
| 3683 |
// content-loss bug we are closing (the model confidently rewrites a long block |
| 3684 |
// it only half-saw and drops the tail). With the marker it knows the copy is |
| 3685 |
// incomplete and preserves/appends instead of replacing blind. |
| 3686 |
function clip( s, max ) { |
| 3687 |
return s.length > max ? s.slice( 0, max ) + ' …[+' + ( s.length - max ) + ' more chars not shown]' : s; |
| 3688 |
} |
| 3689 |
|
| 3690 |
// Plain-text of a block's own copy — the brain identifies the block by it AND, |
| 3691 |
// for a plain (no-markup) leaf, rewrites THIS string. Full copy up to the read |
| 3692 |
// budget so a content edit sees the whole thing, not a stub. |
| 3693 |
function textOf( block ) { |
| 3694 |
const a = block.attributes || {}; |
| 3695 |
const raw = a.text || a.content || a.label || a.title || ''; |
| 3696 |
return clip( String( raw ).replace( /<[^>]*>/g, '' ).trim(), CONTENT_READ_MAX ); |
| 3697 |
} |
| 3698 |
|
| 3699 |
// RICH content WITH inline markup intact — the carrier of the page's design |
| 3700 |
// PATTERN (per-word colour <span>s, emphasis). `text` above is stripped for |
| 3701 |
// identification; this is what the brain must edit so a content rewrite can |
| 3702 |
// re-author the SAME inline structure with new words instead of flattening |
| 3703 |
// it to a plain string (the headline-flattening defect). Surfaced ONLY when |
| 3704 |
// the content actually carries inline markup, so plain blocks stay lean. |
| 3705 |
function htmlOf( block ) { |
| 3706 |
const a = block.attributes || {}; |
| 3707 |
// eslint-disable-next-line eqeqeq |
| 3708 |
const raw = a.content != null ? a.content : ( a.text != null ? a.text : null ); |
| 3709 |
if ( typeof raw !== 'string' || raw.indexOf( '<' ) === -1 ) { |
| 3710 |
return null; |
| 3711 |
} |
| 3712 |
return clip( raw, CONTENT_READ_MAX ); |
| 3713 |
} |
| 3714 |
|
| 3715 |
// The live editor canvas document (iframe in the block editor; falls back to |
| 3716 |
// the main document for the no-iframe mount). Shared shape with apply-change's |
| 3717 |
// canvasCtx — kept minimal here (read-only lookup) to avoid a cross-handler |
| 3718 |
// load-order dependency. |
| 3719 |
function canvasDoc() { |
| 3720 |
const f = document.querySelector( 'iframe[name="editor-canvas"]' ); |
| 3721 |
return f && f.contentDocument ? f.contentDocument : document; |
| 3722 |
} |
| 3723 |
|
| 3724 |
// The canvas node for a block (the live rendered element). Resolved ONCE per |
| 3725 |
// row by the caller and shared by computedOf + renderedContentOf so the |
| 3726 |
// [data-block] lookup runs once, not per-field. null when not mounted |
| 3727 |
// (off-screen/virtualized). |
| 3728 |
function nodeForBlock( doc, clientId ) { |
| 3729 |
return doc.querySelector( '[data-block="' + clientId + '"]' ); |
| 3730 |
} |
| 3731 |
|
| 3732 |
// RENDERED design facts for a block — the load-bearing axes the brain |
| 3733 |
// otherwise cannot perceive (it sees authored class tokens, never the |
| 3734 |
// rendered px/colour). Lets "bigger" be relative to the real size and lets |
| 3735 |
// the brain see when an authored class did NOT move a property (a gs-* |
| 3736 |
// !important rule owns it). Best-effort: null when the canvas node isn't |
| 3737 |
// mounted (off-screen/virtualized) — the doctrine then falls back to |
| 3738 |
// className. Read-only; never throws. |
| 3739 |
function computedOf( el, win ) { |
| 3740 |
try { |
| 3741 |
if ( ! el ) { |
| 3742 |
return null; |
| 3743 |
} |
| 3744 |
const cs = win.getComputedStyle( el ); |
| 3745 |
return { |
| 3746 |
fontSize: cs.fontSize, |
| 3747 |
color: cs.color, |
| 3748 |
backgroundColor: cs.backgroundColor, |
| 3749 |
padding: cs.padding, |
| 3750 |
fontWeight: cs.fontWeight, |
| 3751 |
}; |
| 3752 |
} catch ( e ) { |
| 3753 |
return null; |
| 3754 |
} |
| 3755 |
} |
| 3756 |
|
| 3757 |
// Collapse whitespace + trim — for the RENDERED DOM text, which the browser |
| 3758 |
// already decoded to plain text (no entities/tags). Deliberately NOT routed |
| 3759 |
// through an innerHTML round-trip: that would mangle a literal "<" in the text. |
| 3760 |
function collapseWs( s ) { |
| 3761 |
return ( s === null || s === undefined ? '' : String( s ) ).replace( /\s+/g, ' ' ).trim(); |
| 3762 |
} |
| 3763 |
|
| 3764 |
// Decode HTML entities + strip tags + collapse whitespace — for the AUTHORED |
| 3765 |
// attr, which may carry entities/markup. A detached element (cached, plain |
| 3766 |
// `document` — decoding is document-agnostic) does the exact decode the browser |
| 3767 |
// does; under jest (no DOM) it degrades to a tag strip. Pairs with collapseWs |
| 3768 |
// so authored + rendered compare like-for-like — entity/markup/whitespace |
| 3769 |
// differences never cause a false divergence. |
| 3770 |
let _decodeEl = null; |
| 3771 |
function normContent( s ) { |
| 3772 |
let str = s === null || s === undefined ? '' : String( s ); |
| 3773 |
try { |
| 3774 |
if ( _decodeEl === null ) { |
| 3775 |
_decodeEl = document.createElement( 'div' ); |
| 3776 |
} |
| 3777 |
_decodeEl.innerHTML = str; |
| 3778 |
str = _decodeEl.textContent || ''; |
| 3779 |
} catch ( e ) { |
| 3780 |
str = str.replace( /<[^>]*>/g, '' ); |
| 3781 |
} |
| 3782 |
return str.replace( /\s+/g, ' ' ).trim(); |
| 3783 |
} |
| 3784 |
|
| 3785 |
// CONTENT OWNERSHIP (the content analog of get-styles' styleContext): the |
| 3786 |
// RENDERED text of a LEAF content block vs its own authored content attr. |
| 3787 |
// When they DIFFER, the block's own attr is a DEAD LEVER — the rendered value |
| 3788 |
// is owned UPSTREAM (a parent composite computes it / pushes it via block |
| 3789 |
// context: a countdown unit's label comes from the parent's `{unit}sLabel`). |
| 3790 |
// Editing the child attr then silently no-ops on render. Surfacing the |
| 3791 |
// rendered value (NOT a server-computed boolean — the brain compares `text` |
| 3792 |
// vs `rendered` and judges) lets the brain redirect the edit to the owning |
| 3793 |
// parent attr, WITHOUT any per-block knowledge — the render is the source of |
| 3794 |
// truth, exactly like styleContext's `effective`. |
| 3795 |
// |
| 3796 |
// Scoped HARD to avoid false positives: LEAF blocks only (a container's |
| 3797 |
// textContent is its whole subtree → would always "differ"); only TEXT attrs |
| 3798 |
// (content/text/label) — NOT `number`, which leaves self-format/animate |
| 3799 |
// (1000→"1,000", count-up mid-flight), so value-inequality there is not |
| 3800 |
// ownership; both sides normalized; empty/partial renders (unmounted, or |
| 3801 |
// mid-typewriter where rendered is a prefix of authored) are skipped. Takes the |
| 3802 |
// pre-resolved canvas node. Returns the normalized rendered value, or null. |
| 3803 |
function renderedContentOf( block, el ) { |
| 3804 |
if ( ( block.innerBlocks || [] ).length > 0 ) { |
| 3805 |
return null; |
| 3806 |
} // leaf-only — never a container subtree |
| 3807 |
const a = block.attributes || {}; |
| 3808 |
// eslint-disable-next-line eqeqeq |
| 3809 |
const authoredRaw = a.content != null ? a.content : a.text != null ? a.text : a.label != null ? a.label : null; |
| 3810 |
if ( authoredRaw === null || authoredRaw === '' ) { |
| 3811 |
return null; |
| 3812 |
} |
| 3813 |
if ( ! el ) { |
| 3814 |
return null; |
| 3815 |
} |
| 3816 |
try { |
| 3817 |
const rendered = collapseWs( el.textContent || '' ); |
| 3818 |
if ( ! rendered ) { |
| 3819 |
return null; |
| 3820 |
} // unmounted / cleared (mid-stream) |
| 3821 |
// indexOf===0 covers BOTH equality (child owns it) and rendered being a |
| 3822 |
// strict prefix of authored (mid-typewriter) — either way, not shadowed. |
| 3823 |
if ( normContent( authoredRaw ).indexOf( rendered ) === 0 ) { |
| 3824 |
return null; |
| 3825 |
} |
| 3826 |
return rendered.slice( 0, 120 ); |
| 3827 |
} catch ( e ) { |
| 3828 |
return null; |
| 3829 |
} |
| 3830 |
} |
| 3831 |
|
| 3832 |
// Current className (L2 styling). Surfaced so an apply_change.setAttributes |
| 3833 |
// can PRESERVE the existing utility/gs classes instead of replacing them |
| 3834 |
// wholesale — the brain appends to this string. |
| 3835 |
function classOf( block ) { |
| 3836 |
const a = block.attributes || {}; |
| 3837 |
return typeof a.className === 'string' ? a.className : ''; |
| 3838 |
} |
| 3839 |
|
| 3840 |
// The 8 GBS-banned visual styling props are NEVER advertised as authorable |
| 3841 |
// attrs (styling lives in `className` ONLY, the Spectra GBS JIT grammar). The |
| 3842 |
// list is the SHARED SSOT (editor-shared-utils.isBannedVisualAttr) so this + |
| 3843 |
// apply-change can never drift. If the shared module isn't resolved we |
| 3844 |
// advertise the raw registry keys (the brain's zod still rejects the 8 on |
| 3845 |
// write — degrade consistently, never a duplicate list). |
| 3846 |
function isBannedVisualAttr( key ) { |
| 3847 |
const u = sharedEditorUtils(); |
| 3848 |
return !! ( u && typeof u.isBannedVisualAttr === 'function' && u.isBannedVisualAttr( key ) ); |
| 3849 |
} |
| 3850 |
|
| 3851 |
// The block's VALID structural attr keys — straight from the registry |
| 3852 |
// (getBlockType(blockName).attributes, the browser-side SSOT for what |
| 3853 |
// Gutenberg accepts) minus the 8 GBS-banned styling attrs. Surfaced so the |
| 3854 |
// model authors valid `attrs` first-try (e.g. counter `endNumber`, button |
| 3855 |
// `linkURL`, container `isRootBlock`) instead of guessing against a brain |
| 3856 |
// allowlist — the registry, not a hand-maintained list, decides validity. |
| 3857 |
// null when the type isn't registered (forked/unknown block) → row omits the |
| 3858 |
// field rather than advertise an empty/false set. |
| 3859 |
function attrKeysOf( name ) { |
| 3860 |
const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType |
| 3861 |
? window.wp.blocks.getBlockType( name ) |
| 3862 |
: null; |
| 3863 |
if ( ! t || ! t.attributes ) { |
| 3864 |
return null; |
| 3865 |
} |
| 3866 |
const out = []; |
| 3867 |
Object.keys( t.attributes ).forEach( function ( k ) { |
| 3868 |
// `spectraId` is an internal identifier that renders as a conditional |
| 3869 |
// `data-spectra-id` ATTRIBUTE, never a queryable class/id — advertising |
| 3870 |
// it as authorable lured the model into `querySelector('.<spectraId>')` |
| 3871 |
// dead selectors. The sanctioned identity channel is `anchor` (→ the |
| 3872 |
// element's frontend `id`), surfaced at the row level instead. |
| 3873 |
if ( k === 'spectraId' ) { |
| 3874 |
return; |
| 3875 |
} |
| 3876 |
if ( ! isBannedVisualAttr( k ) ) { |
| 3877 |
out.push( k ); |
| 3878 |
} |
| 3879 |
} ); |
| 3880 |
return out; |
| 3881 |
} |
| 3882 |
|
| 3883 |
// SET (non-default) attribute VALUES — the higher-specificity styling/layout |
| 3884 |
// layer that OUTRANKS className (cascade: block attribute > GBS class > block |
| 3885 |
// default). Surfaced so the model SEES a pinned `style.*` / `layout` / spacing |
| 3886 |
// attr BEFORE it sets a class that would silently no-op against it, and can |
| 3887 |
// clear/move it. Uses the PROPER Gutenberg notion of "comment attributes": walk |
| 3888 |
// the registered schema (getBlockType), drop attrs SOURCE-d from markup |
| 3889 |
// (content/html — those are the text, not a styling override) and attrs still at |
| 3890 |
// their declared `default` — exactly the set Gutenberg's serializer persists. |
| 3891 |
// FULLY registry-driven — no hardcoded key list: only the schema's own `source` |
| 3892 |
// (markup-backed) + `default` + the banned visual props filter it. So whatever |
| 3893 |
// is pinned (style.*, layout, spacing, …) surfaces; the model picks the ones |
| 3894 |
// relevant to its edit. null when nothing is overridden. |
| 3895 |
function setAttrsOf( block ) { |
| 3896 |
const t = window.wp && window.wp.blocks && window.wp.blocks.getBlockType |
| 3897 |
? window.wp.blocks.getBlockType( block.name ) |
| 3898 |
: null; |
| 3899 |
if ( ! t || ! t.attributes ) { |
| 3900 |
return null; |
| 3901 |
} |
| 3902 |
const a = block.attributes || {}; |
| 3903 |
const schema = t.attributes; |
| 3904 |
const out = {}; |
| 3905 |
Object.keys( schema ).forEach( function ( k ) { |
| 3906 |
const def = schema[ k ] || {}; |
| 3907 |
if ( def.source ) { |
| 3908 |
return; |
| 3909 |
} // sourced from the markup — it's the content, not a styling override |
| 3910 |
if ( isBannedVisualAttr( k ) ) { |
| 3911 |
return; |
| 3912 |
} // never authorable in attrs anyway |
| 3913 |
const v = a[ k ]; |
| 3914 |
if ( v === undefined || v === null || v === '' ) { |
| 3915 |
return; |
| 3916 |
} |
| 3917 |
if ( Array.isArray( v ) && v.length === 0 ) { |
| 3918 |
return; |
| 3919 |
} |
| 3920 |
if ( typeof v === 'object' && ! Array.isArray( v ) && Object.keys( v ).length === 0 ) { |
| 3921 |
return; |
| 3922 |
} |
| 3923 |
try { |
| 3924 |
if ( JSON.stringify( v ) === JSON.stringify( def.default ) ) { |
| 3925 |
return; |
| 3926 |
} // unchanged from default |
| 3927 |
} catch ( e ) { /* unstringifiable → treat as a real override */ } |
| 3928 |
out[ k ] = v; |
| 3929 |
} ); |
| 3930 |
return Object.keys( out ).length ? out : null; |
| 3931 |
} |
| 3932 |
|
| 3933 |
function rowOf( block, path ) { |
| 3934 |
const row = { |
| 3935 |
clientId: block.clientId, |
| 3936 |
blockName: block.name, |
| 3937 |
text: textOf( block ), |
| 3938 |
className: classOf( block ), |
| 3939 |
path, |
| 3940 |
}; |
| 3941 |
// The block's HTML `anchor` → its frontend `id` (deterministic: |
| 3942 |
// BlockAttributes wrapper sets id = anchor). The sanctioned way to TARGET |
| 3943 |
// this block from custom JS is `#<anchor>` — surfaced beside className so |
| 3944 |
// the model reads/reuses a real id instead of guessing a frontend class. |
| 3945 |
const anchor = block.attributes && typeof block.attributes.anchor === 'string' ? block.attributes.anchor : ''; |
| 3946 |
if ( anchor !== '' ) { |
| 3947 |
row.anchor = anchor; |
| 3948 |
} |
| 3949 |
// Registry-declared valid attr keys (minus GBS-banned) so the model |
| 3950 |
// authors valid setAttributes.attrs first-try. Omitted for unregistered |
| 3951 |
// types — never advertise a guess. |
| 3952 |
const attrKeys = attrKeysOf( block.name ); |
| 3953 |
if ( attrKeys !== null ) { |
| 3954 |
row.attrKeys = attrKeys; |
| 3955 |
} |
| 3956 |
// Pinned (non-default) attr VALUES — the higher-specificity layer that |
| 3957 |
// overrides className. Lets the model reconcile specificity before a class no-ops. |
| 3958 |
const setAttrs = setAttrsOf( block ); |
| 3959 |
if ( setAttrs !== null ) { |
| 3960 |
row.setAttrs = setAttrs; |
| 3961 |
} |
| 3962 |
// Design-faithful enrichment — only present when meaningful, so the |
| 3963 |
// payload stays lean: `html` for blocks carrying inline markup, `computed` |
| 3964 |
// when the live canvas node is readable. The canvas node is resolved ONCE |
| 3965 |
// here and shared by computedOf + renderedContentOf (one [data-block] |
| 3966 |
// lookup per row, not per field). |
| 3967 |
const html = htmlOf( block ); |
| 3968 |
if ( html !== null ) { |
| 3969 |
row.html = html; |
| 3970 |
} |
| 3971 |
const cdoc = canvasDoc(); |
| 3972 |
const cel = nodeForBlock( cdoc, block.clientId ); |
| 3973 |
const computed = computedOf( cel, cdoc.defaultView || window ); |
| 3974 |
if ( computed !== null ) { |
| 3975 |
row.computed = computed; |
| 3976 |
} |
| 3977 |
// Content ownership: present ONLY when the rendered text differs from this |
| 3978 |
// leaf's own content attr — i.e. the attr is a dead lever and the value is |
| 3979 |
// owned upstream (a parent composite). The brain compares `text` vs |
| 3980 |
// `rendered` and redirects the edit to the parent's owning attr. |
| 3981 |
const rendered = renderedContentOf( block, cel ); |
| 3982 |
if ( rendered !== null ) { |
| 3983 |
row.rendered = rendered; |
| 3984 |
} |
| 3985 |
return row; |
| 3986 |
} |
| 3987 |
|
| 3988 |
// Read bounds. An unbounded subtree dump on a large/whole page can exceed the |
| 3989 |
// brain's compaction backstop and clear the active read with NO recovery |
| 3990 |
// (the brain holds only the js_rpc envelope, not this payload). Cap rows + |
| 3991 |
// depth + total bytes so a single read is always self-contained; a truncated |
| 3992 |
// read flags `truncated` and stamps per-parent `childCount`/`hasMore` so the |
| 3993 |
// model drills down with a narrower scope instead of getting a silent cut. |
| 3994 |
const MAX_CONTEXT_ROWS = 250; |
| 3995 |
const MAX_CONTEXT_DEPTH = 8; |
| 3996 |
const MAX_CONTEXT_BYTES = 48000; |
| 3997 |
|
| 3998 |
// Push a row into the bounded accumulator. Returns false (caller stops) when |
| 3999 |
// a cap is hit. Tracks an approximate serialized size so a few heavy rows |
| 4000 |
// (long html / many attrKeys) trip the ceiling as readily as many light ones. |
| 4001 |
function pushRow( state, row ) { |
| 4002 |
if ( state.rows.length >= MAX_CONTEXT_ROWS ) { |
| 4003 |
state.truncated = true; return false; |
| 4004 |
} |
| 4005 |
let sz = 200; |
| 4006 |
try { |
| 4007 |
sz = JSON.stringify( row ).length; |
| 4008 |
} catch ( e ) { |
| 4009 |
sz = 200; |
| 4010 |
} |
| 4011 |
if ( state.bytes + sz > MAX_CONTEXT_BYTES && state.rows.length > 0 ) { |
| 4012 |
state.truncated = true; |
| 4013 |
return false; |
| 4014 |
} |
| 4015 |
state.rows.push( row ); |
| 4016 |
state.bytes += sz; |
| 4017 |
return true; |
| 4018 |
} |
| 4019 |
|
| 4020 |
// Flatten a block + descendants into bounded rows (DFS): clientId (live |
| 4021 |
// handle), block name, text snippet, className, dot-path. Stops at the |
| 4022 |
// row/byte caps; at the depth cap it emits the node with childCount/hasMore |
| 4023 |
// rather than descending, so deeper blocks stay discoverable via a scoped |
| 4024 |
// re-read. |
| 4025 |
function flatten( block, path, depth, state ) { |
| 4026 |
const inner = block.innerBlocks || []; |
| 4027 |
const row = rowOf( block, path ); |
| 4028 |
if ( inner.length > 0 && depth >= MAX_CONTEXT_DEPTH ) { |
| 4029 |
row.childCount = inner.length; |
| 4030 |
row.hasMore = true; |
| 4031 |
pushRow( state, row ); |
| 4032 |
return; |
| 4033 |
} |
| 4034 |
if ( ! pushRow( state, row ) ) { |
| 4035 |
return; |
| 4036 |
} |
| 4037 |
for ( let i = 0; i < inner.length; i++ ) { |
| 4038 |
if ( state.truncated ) { |
| 4039 |
// The caps tripped before this parent's children were exhausted — |
| 4040 |
// flag it so the omitted subtree is discoverable. |
| 4041 |
row.childCount = inner.length; |
| 4042 |
row.hasMore = true; |
| 4043 |
return; |
| 4044 |
} |
| 4045 |
flatten( inner[ i ], path === '' ? String( i ) : path + '.' + i, depth + 1, state ); |
| 4046 |
} |
| 4047 |
} |
| 4048 |
|
| 4049 |
// Shared editor utilities — resolved at call time from the ONE source |
| 4050 |
// (editor/shared/editor-shared-utils.js): window in the browser, require() |
| 4051 |
// under jest. currentPostId lives there so this handler and apply-change can |
| 4052 |
// never drift; a missing module degrades consistently (null). |
| 4053 |
function sharedEditorUtils() { |
| 4054 |
if ( typeof window !== 'undefined' && window.zipwpEditorShared ) { |
| 4055 |
return window.zipwpEditorShared; |
| 4056 |
} |
| 4057 |
if ( typeof require === 'function' ) { |
| 4058 |
try { |
| 4059 |
return require( '../shared/editor-shared-utils.js' ); |
| 4060 |
} catch ( e ) { |
| 4061 |
return null; |
| 4062 |
} |
| 4063 |
} |
| 4064 |
return null; |
| 4065 |
} |
| 4066 |
function currentPostId() { |
| 4067 |
const u = sharedEditorUtils(); |
| 4068 |
return u && u.currentPostId ? u.currentPostId() : null; |
| 4069 |
} |
| 4070 |
|
| 4071 |
function handleGetContext( args ) { |
| 4072 |
if ( ! window.wp || ! window.wp.data ) { |
| 4073 |
return { success: false, error: 'block_editor_unavailable' }; |
| 4074 |
} |
| 4075 |
const sel = window.wp.data.select( 'core/block-editor' ); |
| 4076 |
if ( ! sel ) { |
| 4077 |
return { success: false, error: 'block_editor_unavailable' }; |
| 4078 |
} |
| 4079 |
|
| 4080 |
// A scope that isn't a real clientId — empty, or a sentinel the model |
| 4081 |
// sometimes emits when it means "the whole page" (the literal string |
| 4082 |
// "null", "root", "page", …) — degrades to the page outline rather than |
| 4083 |
// a hard miss. The outline is the cheap top-level map the model can |
| 4084 |
// re-target from; a hard error here just burns a round-trip. |
| 4085 |
const scopeRaw = args && args.scope ? String( args.scope ) : ''; |
| 4086 |
const SCOPE_SENTINELS = { '': 1, null: 1, undefined: 1, root: 1, page: 1, 0: 1, none: 1, false: 1 }; |
| 4087 |
let scope = SCOPE_SENTINELS[ scopeRaw.toLowerCase() ] ? '' : scopeRaw; |
| 4088 |
// Bounded accumulator (rows + running byte size + truncation flag). |
| 4089 |
const state = { rows: [], bytes: 0, truncated: false }; |
| 4090 |
|
| 4091 |
// A stale/rotated clientId — gone after a structural edit (replace/insert |
| 4092 |
// mint new ids), a page change, or replayed from older conversation history |
| 4093 |
// — must NOT dead-end the turn. A hard `scope_not_found` error makes the |
| 4094 |
// model re-ask the SAME dead id (wasted round-trips, duplicate-call blocks, |
| 4095 |
// token bloat). Instead DEGRADE to the page outline and FLAG the miss, so the |
| 4096 |
// model re-grounds against the live tree and re-targets in the SAME turn. The |
| 4097 |
// grounding tool is the model's only map; it must always return a usable one. |
| 4098 |
let scopeMissed = ''; |
| 4099 |
if ( scope ) { |
| 4100 |
const root = sel.getBlock( scope ); |
| 4101 |
if ( root ) { |
| 4102 |
flatten( root, '', 0, state ); |
| 4103 |
} else { |
| 4104 |
scopeMissed = scope; |
| 4105 |
scope = ''; |
| 4106 |
} |
| 4107 |
} |
| 4108 |
if ( ! scope ) { |
| 4109 |
const top = sel.getBlocks() || []; |
| 4110 |
for ( let i = 0; i < top.length; i++ ) { |
| 4111 |
// Outline read = top-level sections only (no recurse). Still |
| 4112 |
// row/byte-bounded so a page with hundreds of sections can't |
| 4113 |
// blow the read. |
| 4114 |
if ( ! pushRow( state, rowOf( top[ i ], String( i ) ) ) ) { |
| 4115 |
break; |
| 4116 |
} |
| 4117 |
} |
| 4118 |
} |
| 4119 |
const blocks = state.rows; |
| 4120 |
|
| 4121 |
const data = { |
| 4122 |
scope: scope || null, |
| 4123 |
postId: currentPostId(), |
| 4124 |
blocks, |
| 4125 |
}; |
| 4126 |
// The read hit a cap (rows/bytes) — tell the model these rows are a |
| 4127 |
// PREFIX, so it narrows the scope (re-read a child clientId) rather than |
| 4128 |
// assuming it saw the whole tree. Pairs with per-parent childCount/hasMore. |
| 4129 |
if ( state.truncated ) { |
| 4130 |
data.truncated = true; |
| 4131 |
} |
| 4132 |
// Flag a degraded read so the model KNOWS the id it asked for is gone and |
| 4133 |
// these rows are the live outline to re-target from (not the requested |
| 4134 |
// subtree) — turns a silent substitution into an explicit re-ground signal. |
| 4135 |
if ( scopeMissed ) { |
| 4136 |
data.scopeNotFound = scopeMissed; |
| 4137 |
} |
| 4138 |
return { success: true, data }; |
| 4139 |
} |
| 4140 |
|
| 4141 |
// Register once the bridge is ready (same retry pattern as the other |
| 4142 |
// editor tools — the bridge mounts asynchronously). |
| 4143 |
function initHandler() { |
| 4144 |
if ( window.zipwpMcp && window.zipwpMcp.registerTool ) { |
| 4145 |
window.zipwpMcp.registerTool( |
| 4146 |
'editor/get-context', |
| 4147 |
async ( args ) => handleGetContext( args ), |
| 4148 |
{ previewMode: 'client' } |
| 4149 |
); |
| 4150 |
} else { |
| 4151 |
setTimeout( initHandler, 100 ); |
| 4152 |
} |
| 4153 |
} |
| 4154 |
|
| 4155 |
initHandler(); |
| 4156 |
}() ); |
| 4157 |
|
| 4158 |
|
| 4159 |
/** |
| 4160 |
* editor/get-scripts + editor/set-scripts — typed read/patch over a BLOCK's |
| 4161 |
* `spectraCustomJS` attribute (the per-block JS store). `spectraCustomJS` in |
| 4162 |
* post_content is the single source of truth for page behaviour — Spectra Pro's |
| 4163 |
* BlockJsCompiler renders it once at wp_footer (spectra-blocks-pro #165), |
| 4164 |
* superseding the removed per-page `_spectra_blocks_page_scripts` meta. |
| 4165 |
* |
| 4166 |
* SESSION-SCOPED BY DESIGN (the apply_change philosophy): reads come from |
| 4167 |
* `core/block-editor` getBlockAttributes (the live editing session, incl. |
| 4168 |
* unsaved edits) and writes go through updateBlockAttributes — the block's JS |
| 4169 |
* updates in the session immediately, is DISCARDED if the user discards the |
| 4170 |
* session, and persists only on Save. A REST write here would race the open |
| 4171 |
* editor's copy and mutate before save — never do that. |
| 4172 |
* |
| 4173 |
* Target a block by `clientId` (default = the page root container, the first |
| 4174 |
* top-level block — page-wide behaviour). The stored code is raw JS; the |
| 4175 |
* renderer wraps it in an IIFE and resolves the `_current_block_` token to the |
| 4176 |
* block's scope class. |
| 4177 |
*/ |
| 4178 |
( function () { |
| 4179 |
// Block-editor store access comes from the ONE shared source |
| 4180 |
// (editor/shared/editor-shared-utils.js): window in the browser, require() |
| 4181 |
// under jest — so the editor handlers can't drift. |
| 4182 |
function sharedEditorUtils() { |
| 4183 |
if ( typeof window !== 'undefined' && window.zipwpEditorShared ) { |
| 4184 |
return window.zipwpEditorShared; |
| 4185 |
} |
| 4186 |
if ( typeof require === 'function' ) { |
| 4187 |
try { |
| 4188 |
return require( '../shared/editor-shared-utils.js' ); |
| 4189 |
} catch ( e ) { |
| 4190 |
return null; |
| 4191 |
} |
| 4192 |
} |
| 4193 |
return null; |
| 4194 |
} |
| 4195 |
function blockEditorSelect() { |
| 4196 |
const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null; |
| 4197 |
} |
| 4198 |
function blockEditorDispatch() { |
| 4199 |
const u = sharedEditorUtils(); return u && u.blockEditorDispatch ? u.blockEditorDispatch() : null; |
| 4200 |
} |
| 4201 |
function rootClientId( sel ) { |
| 4202 |
const u = sharedEditorUtils(); return u && u.rootClientId ? u.rootClientId( sel ) : null; |
| 4203 |
} |
| 4204 |
function currentPostId() { |
| 4205 |
const u = sharedEditorUtils(); return u && u.currentPostId ? u.currentPostId() : null; |
| 4206 |
} |
| 4207 |
function isJsCapable( blockName ) { |
| 4208 |
const u = sharedEditorUtils(); return !! ( u && u.isJsCapable && u.isJsCapable( blockName ) ); |
| 4209 |
} |
| 4210 |
|
| 4211 |
// ── Anchor resolution for JS selectors ────────────────────────────────── |
| 4212 |
// The AI can't see the rendered DOM. The sanctioned way to target a block in |
| 4213 |
// JS is its HTML `anchor`, which renders as the element's frontend `id` |
| 4214 |
// (BlockAttributes::get_wrapper_attributes → `$wrapper_attrs['id'] = anchor`). |
| 4215 |
// collectAnchors gathers those; unresolvedSelectors reports the #ids a script |
| 4216 |
// references that no anchor provides. Those are surfaced to the brain as a |
| 4217 |
// FACT, not hard-blocked — the id may still resolve on the frontend (an |
| 4218 |
// imported header/footer part the editor's page tree omits, or an id authored |
| 4219 |
// inside block content: form fields, rich-text spans). Class/tag selectors are |
| 4220 |
// never gated — they name runtime state, utilities, and block-internal DOM. |
| 4221 |
function collectAnchors( sel ) { |
| 4222 |
const anchors = Object.create( null ); |
| 4223 |
const ids = sel.getClientIdsWithDescendants ? sel.getClientIdsWithDescendants() : null; |
| 4224 |
if ( ! ids ) { |
| 4225 |
return anchors; |
| 4226 |
} |
| 4227 |
for ( let i = 0; i < ids.length; i++ ) { |
| 4228 |
const a = sel.getBlockAttributes( ids[ i ] ); |
| 4229 |
if ( a && typeof a.anchor === 'string' && a.anchor !== '' ) { |
| 4230 |
anchors[ a.anchor ] = true; |
| 4231 |
} |
| 4232 |
} |
| 4233 |
return anchors; |
| 4234 |
} |
| 4235 |
|
| 4236 |
// Every id the JS references (getElementById / #id) — from the shared SSOT, so |
| 4237 |
// the write-time gate parses JS identically to apply-change's delete-time |
| 4238 |
// orphan-JS surface (drift here would be a new bug class). Local fallback keeps |
| 4239 |
// the handler working if the shared bundle hasn't loaded yet. |
| 4240 |
function referencedIds( code ) { |
| 4241 |
const u = sharedEditorUtils(); |
| 4242 |
if ( u && u.referencedIds ) { |
| 4243 |
return u.referencedIds( code ); |
| 4244 |
} |
| 4245 |
const ids = []; |
| 4246 |
if ( typeof code !== 'string' || code === '' ) { |
| 4247 |
return ids; |
| 4248 |
} |
| 4249 |
let m; |
| 4250 |
const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g; |
| 4251 |
while ( ( m = reGid.exec( code ) ) !== null ) { |
| 4252 |
ids.push( m[ 1 ] ); |
| 4253 |
} |
| 4254 |
const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g; |
| 4255 |
while ( ( m = reQs.exec( code ) ) !== null ) { |
| 4256 |
// eslint-disable-next-line no-var |
| 4257 |
var idm, |
| 4258 |
reId = /#([A-Za-z][\w-]*)/g; |
| 4259 |
while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) { |
| 4260 |
ids.push( idm[ 1 ] ); |
| 4261 |
} |
| 4262 |
} |
| 4263 |
return ids; |
| 4264 |
} |
| 4265 |
|
| 4266 |
// The #ids the JS references that no block ANCHOR in THIS page's editor tree |
| 4267 |
// provides. Surfaced to the brain as a FACT — NOT hard-blocked: the id may |
| 4268 |
// still resolve on the frontend (an imported header/footer part the editor |
| 4269 |
// tree omits, or an id authored inside block content — form fields, rich-text |
| 4270 |
// spans). The brain has the site context to judge; blocking here false-rejects |
| 4271 |
// those legit targets. Mirrors removalImpact (adapter surfaces facts, brain decides). |
| 4272 |
function unresolvedSelectors( sel, code ) { |
| 4273 |
const anchors = collectAnchors( sel ); |
| 4274 |
const ids = referencedIds( code ); |
| 4275 |
return ids.filter( function ( id, i ) { |
| 4276 |
return ids.indexOf( id ) === i && ! anchors[ id ]; |
| 4277 |
} ); |
| 4278 |
} |
| 4279 |
|
| 4280 |
// The target block: an explicit clientId, else the page root container. |
| 4281 |
function resolveClientId( sel, args ) { |
| 4282 |
const cid = args && typeof args.clientId === 'string' && args.clientId !== '' ? args.clientId : null; |
| 4283 |
return cid || rootClientId( sel ); |
| 4284 |
} |
| 4285 |
|
| 4286 |
// A block's current spectraCustomJS ('' when unset or the block is gone). |
| 4287 |
// eslint-disable-next-line no-unused-vars |
| 4288 |
function customJsOf( sel, clientId ) { |
| 4289 |
const attrs = sel && sel.getBlockAttributes ? sel.getBlockAttributes( clientId ) : null; |
| 4290 |
return attrs && typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : ''; |
| 4291 |
} |
| 4292 |
|
| 4293 |
function handleGetScripts( args ) { |
| 4294 |
const sel = blockEditorSelect(); |
| 4295 |
if ( ! sel || ! sel.getBlockAttributes ) { |
| 4296 |
return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' }; |
| 4297 |
} |
| 4298 |
let clientId = resolveClientId( sel, args ); |
| 4299 |
if ( ! clientId ) { |
| 4300 |
return { success: false, error: 'no_block: the page has no blocks to read JS from' }; |
| 4301 |
} |
| 4302 |
// Mirror set-scripts' redirect: a non-JS-capable target's JS lives on the |
| 4303 |
// page root (where set-scripts wrote it), so READ from there — else the |
| 4304 |
// brain reads '' from the original block and re-issues, stacking duplicate JS. |
| 4305 |
if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) { |
| 4306 |
const root = rootClientId( sel ); |
| 4307 |
if ( root ) { |
| 4308 |
clientId = root; |
| 4309 |
} |
| 4310 |
} |
| 4311 |
// Block-gone is NOT the same as no-JS: if the clientId no longer resolves, |
| 4312 |
// report it as a stale target (else the model reads code:'' as "no JS" and |
| 4313 |
// may author a fresh script against a dead block). |
| 4314 |
const attrs = sel.getBlockAttributes( clientId ); |
| 4315 |
if ( ! attrs ) { |
| 4316 |
return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (it may have been deleted — re-read the outline / get-context first)' }; |
| 4317 |
} |
| 4318 |
return { |
| 4319 |
success: true, |
| 4320 |
data: { |
| 4321 |
post_id: currentPostId(), |
| 4322 |
client_id: clientId, |
| 4323 |
code: typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : '', |
| 4324 |
}, |
| 4325 |
}; |
| 4326 |
} |
| 4327 |
|
| 4328 |
// Throws on code the block renderer would reject — typed and loud beats a |
| 4329 |
// silent drop. `<script>` tags never belong in the store (it wraps the raw JS). |
| 4330 |
function validateCode( code ) { |
| 4331 |
if ( typeof code !== 'string' || code === '' ) { |
| 4332 |
throw new Error( 'invalid_input: code is required (the raw JS source, no <script> tags)' ); |
| 4333 |
} |
| 4334 |
if ( /<\/?script/i.test( code ) ) { |
| 4335 |
throw new Error( 'invalid_input: code must be the raw JS source only, with no <script> tags (the store wraps it)' ); |
| 4336 |
} |
| 4337 |
} |
| 4338 |
|
| 4339 |
// `code` REPLACES the block's spectraCustomJS; `append: true` adds to the |
| 4340 |
// existing JS instead (read first with editor/get-scripts). |
| 4341 |
function handleSetScripts( args ) { |
| 4342 |
const sel = blockEditorSelect(); |
| 4343 |
const dis = blockEditorDispatch(); |
| 4344 |
if ( ! sel || ! sel.getBlockAttributes || ! dis || ! dis.updateBlockAttributes ) { |
| 4345 |
return { success: false, error: 'editor_unavailable: core/block-editor store not present (is the block editor open?)' }; |
| 4346 |
} |
| 4347 |
|
| 4348 |
try { |
| 4349 |
validateCode( args ? args.code : undefined ); |
| 4350 |
} catch ( e ) { |
| 4351 |
return { success: false, error: String( e && e.message ? e.message : e ) }; |
| 4352 |
} |
| 4353 |
|
| 4354 |
let clientId = resolveClientId( sel, args ); |
| 4355 |
if ( ! clientId ) { |
| 4356 |
return { success: false, error: 'no_block: the page has no blocks to attach JS to' }; |
| 4357 |
} |
| 4358 |
let attrs = sel.getBlockAttributes( clientId ); |
| 4359 |
if ( ! attrs ) { |
| 4360 |
return { success: false, error: 'unknown_block: no block with clientId ' + clientId + ' in the live tree (read the outline / get-context first)' }; |
| 4361 |
} |
| 4362 |
|
| 4363 |
// The target must actually persist spectraCustomJS, or it paints this |
| 4364 |
// session and silently vanishes on Save. Redirect to the root container — |
| 4365 |
// the same fallback the converter uses for a script whose owner class |
| 4366 |
// sits on a non-capable block — instead of writing something that's lost. |
| 4367 |
let redirected = false; |
| 4368 |
if ( sel.getBlockName && ! isJsCapable( sel.getBlockName( clientId ) ) ) { |
| 4369 |
const root = rootClientId( sel ); |
| 4370 |
// The redirect target must ALSO be JS-capable — a page whose first |
| 4371 |
// top-level block is core/group / core/cover would otherwise take the |
| 4372 |
// write, report success, then drop the attribute on Save. |
| 4373 |
if ( root && sel.getBlockName && ! isJsCapable( sel.getBlockName( root ) ) ) { |
| 4374 |
return { |
| 4375 |
success: false, |
| 4376 |
error: 'block_not_js_capable: this block type cannot hold JavaScript, and the page root container cannot either — wrap the target in a Spectra container that holds JS, then retry.', |
| 4377 |
}; |
| 4378 |
} |
| 4379 |
const rootAttrs = root ? sel.getBlockAttributes( root ) : null; |
| 4380 |
if ( ! rootAttrs ) { |
| 4381 |
return { |
| 4382 |
success: false, |
| 4383 |
error: 'block_not_js_capable: this block type cannot hold JavaScript, and no root container was found to redirect to', |
| 4384 |
}; |
| 4385 |
} |
| 4386 |
redirected = true; |
| 4387 |
clientId = root; |
| 4388 |
attrs = rootAttrs; |
| 4389 |
} |
| 4390 |
|
| 4391 |
// Block-safety guard — the SAME invariant apply-change enforces via |
| 4392 |
// assertMutable (SCENARIO-003), applied here because this is the other path |
| 4393 |
// that mutates a block's attributes. `spectraCustomJS` is a block attribute, |
| 4394 |
// and Gutenberg's UPDATE_BLOCK_ATTRIBUTES reducer does NOT consult the lock |
| 4395 |
// selectors (they gate the UI, not a programmatic dispatch) — so without this |
| 4396 |
// check set_scripts could write JS into a template-locked block, or into a |
| 4397 |
// synced-pattern (core/block) instance's content-locked inner block, which |
| 4398 |
// edits content shared with every other page that uses that pattern. Checked |
| 4399 |
// AFTER the js-capable redirect so it validates the block actually written to. |
| 4400 |
// Degrades to ALLOW when the selector is absent (older Gutenberg), matching |
| 4401 |
// assertMutable — never a false block. |
| 4402 |
if ( typeof sel.canEditBlock === 'function' && sel.canEditBlock( clientId ) === false ) { |
| 4403 |
return { |
| 4404 |
success: false, |
| 4405 |
error: 'locked_block: ' + clientId + ' is locked in the editor (template lock, content lock, or a synced-pattern instance), so its JavaScript cannot be changed here. Edit the pattern itself, or unlock the block.', |
| 4406 |
}; |
| 4407 |
} |
| 4408 |
|
| 4409 |
// Unresolved #id selectors are a FACT for the brain, not a hard block: the |
| 4410 |
// id may resolve on the frontend (imported header/footer part, block content) |
| 4411 |
// even when no anchor in THIS page's editor tree matches. Store the JS and |
| 4412 |
// surface them so the brain can re-target if they're genuinely dead. |
| 4413 |
const unresolved = unresolvedSelectors( sel, args.code ); |
| 4414 |
|
| 4415 |
const existing = typeof attrs.spectraCustomJS === 'string' ? attrs.spectraCustomJS : ''; |
| 4416 |
const next = ( args.append === true && existing !== '' ) ? existing + '\n' + args.code : args.code; |
| 4417 |
dis.updateBlockAttributes( clientId, { spectraCustomJS: next } ); |
| 4418 |
|
| 4419 |
return { |
| 4420 |
success: true, |
| 4421 |
data: { |
| 4422 |
client_id: clientId, |
| 4423 |
code: next, |
| 4424 |
note: ( redirected |
| 4425 |
? 'Session-scoped: the requested block can\'t hold JS (it would be dropped on Save), so it was attached to the page root container instead.' |
| 4426 |
: 'Session-scoped: the block\'s JS updates now; persists when the user saves the page.' ) + |
| 4427 |
( unresolved.length |
| 4428 |
? ' Heads up: #' + unresolved.join( ', #' ) + ' match no block anchor on this page — fine if they resolve in the imported header/footer or block content, otherwise set an anchor or re-target.' |
| 4429 |
: '' ), |
| 4430 |
...( unresolved.length ? { unresolved_selectors: unresolved } : {} ), |
| 4431 |
}, |
| 4432 |
}; |
| 4433 |
} |
| 4434 |
|
| 4435 |
// Register once the bridge is ready (same retry pattern as get-context / |
| 4436 |
// apply-change). The react-manager glob auto-enqueues this file. |
| 4437 |
function initHandler() { |
| 4438 |
if ( window.zipwpMcp && window.zipwpMcp.registerTool ) { |
| 4439 |
window.zipwpMcp.registerTool( |
| 4440 |
'editor/get-scripts', |
| 4441 |
async function ( args ) { |
| 4442 |
return handleGetScripts( args ); |
| 4443 |
}, |
| 4444 |
{ previewMode: 'client' } |
| 4445 |
); |
| 4446 |
window.zipwpMcp.registerTool( |
| 4447 |
'editor/set-scripts', |
| 4448 |
async function ( args ) { |
| 4449 |
return handleSetScripts( args ); |
| 4450 |
}, |
| 4451 |
{ previewMode: 'client' } |
| 4452 |
); |
| 4453 |
} else { |
| 4454 |
setTimeout( initHandler, 100 ); |
| 4455 |
} |
| 4456 |
} |
| 4457 |
|
| 4458 |
initHandler(); |
| 4459 |
|
| 4460 |
// Test-only surface (Node/CommonJS) — inert in the browser bundle. |
| 4461 |
if ( typeof module !== 'undefined' && module.exports ) { |
| 4462 |
module.exports = { |
| 4463 |
handleGetScripts, |
| 4464 |
handleSetScripts, |
| 4465 |
}; |
| 4466 |
} |
| 4467 |
}() ); |
| 4468 |
|
| 4469 |
|
| 4470 |
/** |
| 4471 |
* editor/get-styles + editor/set-styles — the GBS CSS store, symmetric with |
| 4472 |
* editor/get-context (HTML) and editor/get-scripts (JS). |
| 4473 |
* |
| 4474 |
* READ (get-styles) has two scopes: 'page' (this page's payload + the SELECTED |
| 4475 |
* block's ownership styleContext) and 'global' (the site-wide option). Pure reads. |
| 4476 |
* |
| 4477 |
* WRITE (set-styles) is GLOBAL-scope ONLY — the header/footer / site-wide CHROME |
| 4478 |
* WP OPTION (read via GET /global-styles/user-css, written via the shared |
| 4479 |
* /global-styles/sitewide merge route — IMMEDIATE + site-wide, not reversible by |
| 4480 |
* discard). The former scope:'page' WRITE (an IMMEDIATE `/global-styles/save` |
| 4481 |
* write to this post's `spectra_blocks_pro_gs_user_css` meta) was REMOVED: it |
| 4482 |
* persisted a per-block style edit to the DB before the user Saved (no undo) and, |
| 4483 |
* on a SHARED gs- class body, silently restyled every section carrying it. All |
| 4484 |
* per-block styling is now a utility className via editor/apply-change (editor |
| 4485 |
* state, committed on Save). scope:'page' here is rejected, defensively. |
| 4486 |
* |
| 4487 |
* The global write does READ → MERGE only the touched buckets → WRITE (never |
| 4488 |
* full-replace, so importer chrome + user classes survive), then RENDERs the |
| 4489 |
* merged payload via the SSOT GenCssRenderer (REST /global-styles/render) and |
| 4490 |
* injects it into the canvas iframe for live paint. |
| 4491 |
* |
| 4492 |
* @package |
| 4493 |
*/ |
| 4494 |
( function () { |
| 4495 |
// eslint-disable-next-line no-unused-vars |
| 4496 |
const META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key |
| 4497 |
const NS = '/spectra-blocks/v1/global-styles'; |
| 4498 |
|
| 4499 |
// Editor-store access (select/dispatch core/editor + session meta) comes from |
| 4500 |
// the ONE shared source (editor/shared/editor-shared-utils.js): window in the |
| 4501 |
// browser, require() under jest — so the editor handlers can't drift. |
| 4502 |
function sharedEditorUtils() { |
| 4503 |
if ( typeof window !== 'undefined' && window.zipwpEditorShared ) { |
| 4504 |
return window.zipwpEditorShared; |
| 4505 |
} |
| 4506 |
if ( typeof require === 'function' ) { |
| 4507 |
try { |
| 4508 |
return require( '../shared/editor-shared-utils.js' ); |
| 4509 |
} catch ( e ) { |
| 4510 |
return null; |
| 4511 |
} |
| 4512 |
} |
| 4513 |
return null; |
| 4514 |
} |
| 4515 |
function editorSelect() { |
| 4516 |
const u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null; |
| 4517 |
} |
| 4518 |
function blockEditorSelect() { |
| 4519 |
const u = sharedEditorUtils(); return u && u.blockEditorSelect ? u.blockEditorSelect() : null; |
| 4520 |
} |
| 4521 |
// How many blocks on the page carry a gs- token (the reverse `class -> blocks` |
| 4522 |
// edge). >1 means editing this class body restyles other sections too. Shared SSOT. |
| 4523 |
function classUsage( token ) { |
| 4524 |
const u = sharedEditorUtils(); |
| 4525 |
const besel = blockEditorSelect(); |
| 4526 |
if ( ! u || ! u.classDependents || ! besel ) { |
| 4527 |
return 1; |
| 4528 |
} |
| 4529 |
const n = u.classDependents( besel, token, [] ).length; |
| 4530 |
return n > 0 ? n : 1; |
| 4531 |
} |
| 4532 |
function apiFetch( opts ) { |
| 4533 |
if ( ! ( window.wp && window.wp.apiFetch ) ) { |
| 4534 |
return Promise.reject( new Error( 'wp.apiFetch unavailable' ) ); |
| 4535 |
} |
| 4536 |
return window.wp.apiFetch( opts ); |
| 4537 |
} |
| 4538 |
|
| 4539 |
// GBS page-store logic (merge / iframe-inject / the read→merge→save→render→inject |
| 4540 |
// persist) lives in ONE place — editor/shared/editor-shared-utils.js — so this |
| 4541 |
// styling tool and editor/apply-change can't drift. These are thin forwarders, |
| 4542 |
// the same pattern as editorSelect above. |
| 4543 |
function mergePayload( existing, incoming ) { |
| 4544 |
const u = sharedEditorUtils(); |
| 4545 |
return u && u.mergePayload ? u.mergePayload( existing, incoming ) : Object.assign( {}, existing, incoming ); |
| 4546 |
} |
| 4547 |
function canvasDoc() { |
| 4548 |
const u = sharedEditorUtils(); |
| 4549 |
return u && u.canvasDoc ? u.canvasDoc() : document; |
| 4550 |
} |
| 4551 |
function injectCss( elementId, css ) { |
| 4552 |
const u = sharedEditorUtils(); |
| 4553 |
if ( u && u.injectCss ) { |
| 4554 |
u.injectCss( elementId, css ); |
| 4555 |
} |
| 4556 |
} |
| 4557 |
|
| 4558 |
function bucketsOf( payload ) { |
| 4559 |
return Object.keys( payload || {} ).filter( function ( k ) { |
| 4560 |
return k !== 'v'; |
| 4561 |
} ); |
| 4562 |
} |
| 4563 |
|
| 4564 |
// ── STYLE CONTEXT (ownership) ────────────────────────────────────────────── |
| 4565 |
// The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in |
| 4566 |
// descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the |
| 4567 |
// block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit |
| 4568 |
// its current OWNER (update the existing class, don't stack a new one; clear a |
| 4569 |
// block attr that pins it). This resolver answers "who owns each property" so |
| 4570 |
// the agent never hand-resolves specificity. It does NOT compute the exact |
| 4571 |
// frontend winner (the editor canvas inverts utility-vs-gsClass specificity, |
| 4572 |
// and utilities are JIT-compiled, not in the GBS payload) — `effective` is the |
| 4573 |
// rendered truth and the agent's verify-iterate loop corrects any mis-guess. |
| 4574 |
// The property map is small and explicit ON PURPOSE: it is this tool's job. |
| 4575 |
// `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are |
| 4576 |
// kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the |
| 4577 |
// block-attribute path(s) for that property) is non-derivable. |
| 4578 |
// NOTE: ownership matches the EXACT property name — the shorthands `padding` / |
| 4579 |
// `margin` resolve against a rule's `padding` / `margin` declaration, not a |
| 4580 |
// longhand-only rule (`padding-top`). This system's utilities + gs- bodies write |
| 4581 |
// the shorthand, so that is the intended scope; widen this list if a longhand- |
| 4582 |
// only owner ever needs to be surfaced. |
| 4583 |
const STYLE_PROPS = [ |
| 4584 |
{ prop: 'color', attrs: [ 'style.color.text' ] }, |
| 4585 |
{ prop: 'background-color', attrs: [ 'style.color.background', 'background.color' ] }, |
| 4586 |
{ prop: 'padding', attrs: [ 'style.spacing.padding' ] }, |
| 4587 |
{ prop: 'margin', attrs: [ 'style.spacing.margin' ] }, |
| 4588 |
{ prop: 'font-size', attrs: [ 'style.typography.fontSize' ] }, |
| 4589 |
{ prop: 'font-weight', attrs: [ 'style.typography.fontWeight' ] }, |
| 4590 |
{ prop: 'text-align', attrs: [ 'align' ] }, |
| 4591 |
{ prop: 'max-width', attrs: [ 'maxWidth' ] }, |
| 4592 |
// Border / shadow / motion — className-utility-owned (the visual block |
| 4593 |
// attributes for these are BANNED, so there is no block-attribute owner |
| 4594 |
// path: `attrs` is empty and ownership resolves purely from the live |
| 4595 |
// cascade). Tracked so a `border-2 border-red-500`, `rounded-lg`, |
| 4596 |
// `shadow-md`, `animate-*`, or a transform utility applied via apply_change |
| 4597 |
// has an `effective`/`owner` the agent can VERIFY — without these the |
| 4598 |
// resolver was blind to them and the agent could never confirm the edit |
| 4599 |
// (the "the border is applied, the bounce could not be confirmed" hedge). |
| 4600 |
// Longhands (not the `border` shorthand): utilities author border-width / |
| 4601 |
// border-color separately, and getComputedStyle returns longhands. |
| 4602 |
{ prop: 'border-color', attrs: [] }, |
| 4603 |
{ prop: 'border-width', attrs: [] }, |
| 4604 |
{ prop: 'border-radius', attrs: [] }, |
| 4605 |
{ prop: 'box-shadow', attrs: [] }, |
| 4606 |
{ prop: 'animation', attrs: [] }, |
| 4607 |
{ prop: 'transform', attrs: [] }, |
| 4608 |
]; |
| 4609 |
function deepGet( obj, path ) { |
| 4610 |
let cur = obj; |
| 4611 |
const parts = path.split( '.' ); |
| 4612 |
for ( let i = 0; i < parts.length; i++ ) { |
| 4613 |
if ( cur === null || typeof cur !== 'object' ) { |
| 4614 |
return undefined; |
| 4615 |
} |
| 4616 |
cur = cur[ parts[ i ] ]; |
| 4617 |
} |
| 4618 |
return cur; |
| 4619 |
} |
| 4620 |
function gsTokensOf( block ) { |
| 4621 |
const cn = ( block && block.attributes && typeof block.attributes.className === 'string' ) |
| 4622 |
? block.attributes.className : ''; |
| 4623 |
return cn.trim().split( /\s+/ ).filter( function ( t ) { |
| 4624 |
return t && t.indexOf( 'gs-' ) === 0; |
| 4625 |
} ); |
| 4626 |
} |
| 4627 |
function computedForBlock( clientId ) { |
| 4628 |
try { |
| 4629 |
const doc = canvasDoc(); |
| 4630 |
const el = doc.querySelector( '[data-block="' + clientId + '"]' ); |
| 4631 |
if ( ! el ) { |
| 4632 |
return null; |
| 4633 |
} |
| 4634 |
return ( doc.defaultView || window ).getComputedStyle( el ); |
| 4635 |
} catch ( e ) { |
| 4636 |
return null; |
| 4637 |
} |
| 4638 |
} |
| 4639 |
// ── CSSOM CASCADE RESOLUTION ─────────────────────────────────────────────── |
| 4640 |
// The ROOT of ownership: `effective` is read from the rendered cascade, so |
| 4641 |
// `owner` MUST be resolved from that SAME cascade — not an authored guess. The |
| 4642 |
// authored join (attrs + GBS payload) is blind to JIT utility classes (they are |
| 4643 |
// compiled to real CSS rules, never in the GBS payload) and to source-order |
| 4644 |
// ties between two utilities — the exact gap behind "colour set but not |
| 4645 |
// applied" (owner came back `default` while the pixel was clearly painted). We |
| 4646 |
// read the real matched rules from the canvas stylesheets instead. |
| 4647 |
|
| 4648 |
// The live element for a block (the [data-block] node in the canvas), or null |
| 4649 |
// when unmounted (off-screen / virtualized) — then we degrade to the authored |
| 4650 |
// join below, flagged as best-effort. |
| 4651 |
function elForBlock( doc, clientId ) { |
| 4652 |
try { |
| 4653 |
return doc && clientId ? doc.querySelector( '[data-block="' + clientId + '"]' ) : null; |
| 4654 |
} catch ( e ) { |
| 4655 |
return null; |
| 4656 |
} |
| 4657 |
} |
| 4658 |
function computedForEl( el, doc ) { |
| 4659 |
try { |
| 4660 |
return el ? ( doc.defaultView || window ).getComputedStyle( el ) : null; |
| 4661 |
} catch ( e ) { |
| 4662 |
return null; |
| 4663 |
} |
| 4664 |
} |
| 4665 |
|
| 4666 |
// Approximate CSS specificity [ids, classes/attrs/pseudo-classes, types] for a |
| 4667 |
// single (comma-free) selector. Our selectors are single classes (`.gs-x`, |
| 4668 |
// `.text-primary-600`) → [0,1,0]; the ties they create are broken by SOURCE |
| 4669 |
// ORDER, which is the real decider and is tracked separately. |
| 4670 |
function specificityOf( sel ) { |
| 4671 |
const s = String( sel ); |
| 4672 |
const ids = ( s.match( /#[\w-]+/g ) || [] ).length; |
| 4673 |
const classesAttrsPc = |
| 4674 |
( s.match( /\.[\w-]+/g ) || [] ).length + |
| 4675 |
( s.match( /\[[^\]]*\]/g ) || [] ).length + |
| 4676 |
( s.match( /:(?!:)[\w-]+/g ) || [] ).length; |
| 4677 |
const stripped = s |
| 4678 |
.replace( /::?[\w-]+(\([^)]*\))?/g, ' ' ) |
| 4679 |
.replace( /[.#][\w-]+/g, ' ' ) |
| 4680 |
.replace( /\[[^\]]*\]/g, ' ' ); |
| 4681 |
const types = ( stripped.match( /[a-zA-Z][\w-]*/g ) || [] ).length; |
| 4682 |
return [ ids, classesAttrsPc, types ]; |
| 4683 |
} |
| 4684 |
function cmpSpec( a, b ) { |
| 4685 |
for ( let i = 0; i < 3; i++ ) { |
| 4686 |
if ( a[ i ] !== b[ i ] ) { |
| 4687 |
return a[ i ] - b[ i ]; |
| 4688 |
} |
| 4689 |
} |
| 4690 |
return 0; |
| 4691 |
} |
| 4692 |
// Does a conditional group rule (@media / @supports) currently apply? Permissive |
| 4693 |
// on the way in — a group we can't evaluate is INCLUDED (better to surface a |
| 4694 |
// possibly-inactive source than hide the active one), matching the live paint |
| 4695 |
// which the model verifies against `effective` anyway. |
| 4696 |
function groupRuleApplies( doc, rule ) { |
| 4697 |
try { |
| 4698 |
const win = doc.defaultView || window; |
| 4699 |
if ( rule.type === 4 /* MEDIA */ ) { |
| 4700 |
const mt = rule.media && rule.media.mediaText; |
| 4701 |
return ! mt || ! win.matchMedia ? true : win.matchMedia( mt ).matches; |
| 4702 |
} |
| 4703 |
if ( rule.type === 12 /* SUPPORTS */ ) { |
| 4704 |
const ct = rule.conditionText; |
| 4705 |
return ! ct || ! ( win.CSS && win.CSS.supports ) ? true : win.CSS.supports( ct ); |
| 4706 |
} |
| 4707 |
} catch ( e ) { /* permissive */ } |
| 4708 |
return true; |
| 4709 |
} |
| 4710 |
|
| 4711 |
// Every stylesheet declaration of `prop` whose selector MATCHES `el`, sorted in |
| 4712 |
// ascending cascade priority (winner LAST): !important tier, then specificity, |
| 4713 |
// then source order. Reads real CSSOM rules from the canvas — the true source |
| 4714 |
// of the painted value. |
| 4715 |
function matchedDeclarations( el, doc, prop ) { |
| 4716 |
const out = []; |
| 4717 |
let order = 0; |
| 4718 |
function walk( rules ) { |
| 4719 |
for ( let i = 0; i < rules.length; i++ ) { |
| 4720 |
const r = rules[ i ]; |
| 4721 |
if ( r.type === 1 /* STYLE_RULE */ && r.selectorText && r.style ) { |
| 4722 |
order++; |
| 4723 |
const value = r.style.getPropertyValue( prop ); |
| 4724 |
if ( value === '' || value === null || value === undefined ) { |
| 4725 |
continue; |
| 4726 |
} |
| 4727 |
const important = r.style.getPropertyPriority( prop ) === 'important'; |
| 4728 |
let best = null; |
| 4729 |
String( r.selectorText ).split( ',' ).forEach( function ( part ) { |
| 4730 |
const p = part.trim(); |
| 4731 |
if ( ! p ) { |
| 4732 |
return; |
| 4733 |
} |
| 4734 |
let matches = false; |
| 4735 |
try { |
| 4736 |
matches = el.matches( p ); |
| 4737 |
} catch ( e ) { |
| 4738 |
matches = false; |
| 4739 |
} |
| 4740 |
if ( ! matches ) { |
| 4741 |
return; |
| 4742 |
} |
| 4743 |
const spec = specificityOf( p ); |
| 4744 |
if ( ! best || cmpSpec( spec, best.specificity ) > 0 ) { |
| 4745 |
best = { part: p, specificity: spec }; |
| 4746 |
} |
| 4747 |
} ); |
| 4748 |
if ( best ) { |
| 4749 |
out.push( { selectorPart: best.part, value: String( value ).trim(), important, specificity: best.specificity, order } ); |
| 4750 |
} |
| 4751 |
} else if ( ( r.type === 4 || r.type === 12 ) && r.cssRules && groupRuleApplies( doc, r ) ) { |
| 4752 |
walk( r.cssRules ); |
| 4753 |
} |
| 4754 |
} |
| 4755 |
} |
| 4756 |
const sheets = ( doc && doc.styleSheets ) || []; |
| 4757 |
for ( let s = 0; s < sheets.length; s++ ) { |
| 4758 |
let rules; |
| 4759 |
try { |
| 4760 |
rules = sheets[ s ].cssRules || sheets[ s ].rules; |
| 4761 |
} catch ( e ) { |
| 4762 |
continue; |
| 4763 |
} // cross-origin sheet |
| 4764 |
if ( rules ) { |
| 4765 |
walk( rules ); |
| 4766 |
} |
| 4767 |
} |
| 4768 |
out.sort( function ( a, b ) { |
| 4769 |
return ( a.important ? 1 : 0 ) - ( b.important ? 1 : 0 ) || |
| 4770 |
cmpSpec( a.specificity, b.specificity ) || |
| 4771 |
a.order - b.order; |
| 4772 |
} ); |
| 4773 |
return out; |
| 4774 |
} |
| 4775 |
|
| 4776 |
// Map a single-selector matched rule to an EDITABLE source ON this block: a |
| 4777 |
// `.gs-*` class (edit its body via set_styles) or a utility class (replace it |
| 4778 |
// in className via apply_change). A compound selector (`.parent .child`) isn't |
| 4779 |
// a class the model owns here → surfaced as `selector` so it edits the real |
| 4780 |
// owner (or an ancestor) rather than stacking. |
| 4781 |
function classifySelectorPart( part, el ) { |
| 4782 |
const sel = String( part ).trim(); |
| 4783 |
if ( ! el || ! el.classList ) { |
| 4784 |
return { type: 'selector', selector: sel }; |
| 4785 |
} |
| 4786 |
// Class tokens named anywhere in the selector (a `[class]` attribute selector |
| 4787 |
// or `:root` scoping carries no dot, so it is ignored here). The GBS JIT |
| 4788 |
// boosts per-block rules to `[class].gs-x.gs-x` (attribute + DOUBLED class) |
| 4789 |
// to outrank utilities — the same class repeated is ONE owner, editable via |
| 4790 |
// set_styles. We must see through that, and through `:root .gs-x` scoping. |
| 4791 |
const classTokens = ( sel.match( /\.[\w-]+/g ) || [] ).map( function ( c ) { |
| 4792 |
return c.slice( 1 ); |
| 4793 |
} ); |
| 4794 |
if ( classTokens.length === 0 ) { |
| 4795 |
return { type: 'selector', selector: sel }; |
| 4796 |
} |
| 4797 |
// A SELF-target: every class the selector names is on THIS element (so it is |
| 4798 |
// repetition / attribute-boosting / a structural `:root` scope, not a real |
| 4799 |
// ancestor). A descendant selector like `.wrap .child` names an ancestor |
| 4800 |
// class that ISN'T on el → it stays a contextual `selector:` the model can't |
| 4801 |
// edit here. Exactly one DISTINCT on-el class keeps the owner unambiguous. |
| 4802 |
const distinct = []; |
| 4803 |
let allOnEl = true; |
| 4804 |
classTokens.forEach( function ( c ) { |
| 4805 |
if ( ! el.classList.contains( c ) ) { |
| 4806 |
allOnEl = false; |
| 4807 |
} |
| 4808 |
if ( distinct.indexOf( c ) === -1 ) { |
| 4809 |
distinct.push( c ); |
| 4810 |
} |
| 4811 |
} ); |
| 4812 |
if ( ! allOnEl || distinct.length !== 1 ) { |
| 4813 |
return { type: 'selector', selector: sel }; |
| 4814 |
} |
| 4815 |
const cls = distinct[ 0 ]; |
| 4816 |
return cls.indexOf( 'gs-' ) === 0 |
| 4817 |
? { type: 'gbs', class: cls } |
| 4818 |
: { type: 'utility', class: cls }; |
| 4819 |
} |
| 4820 |
function ownerLabel( src ) { |
| 4821 |
if ( ! src ) { |
| 4822 |
return 'default'; |
| 4823 |
} |
| 4824 |
if ( src.type === 'block_attribute' ) { |
| 4825 |
return 'block_attribute:' + src.path; |
| 4826 |
} |
| 4827 |
if ( src.type === 'gbs' ) { |
| 4828 |
return 'gbs:' + src.class; |
| 4829 |
} |
| 4830 |
if ( src.type === 'utility' ) { |
| 4831 |
return 'utility:' + src.class; |
| 4832 |
} |
| 4833 |
if ( src.type === 'selector' ) { |
| 4834 |
return 'selector:' + src.selector; |
| 4835 |
} |
| 4836 |
return 'default'; |
| 4837 |
} |
| 4838 |
|
| 4839 |
// Cascade-ordered editable sources for ONE property on a MOUNTED block, winner |
| 4840 |
// first. Merges the authored inline attribute (Spectra renders style.* inline, |
| 4841 |
// a normal declaration above class selectors) with the real matched rules, then |
| 4842 |
// sorts by the CSS cascade: !important tier > inline > selectors, each broken by |
| 4843 |
// specificity then source order. |
| 4844 |
function resolveCascadeSources( def, attrs, el, doc, usageByToken ) { |
| 4845 |
const candidates = []; |
| 4846 |
def.attrs.forEach( function ( path ) { |
| 4847 |
const v = deepGet( attrs, path ); |
| 4848 |
if ( v !== undefined && v !== null && v !== '' ) { |
| 4849 |
// level 2 = normal inline; specificity above any class selector. |
| 4850 |
candidates.push( { src: { type: 'block_attribute', path, value: v }, level: 2, specificity: [ 1, 0, 0 ], order: Number.MAX_SAFE_INTEGER } ); |
| 4851 |
} |
| 4852 |
} ); |
| 4853 |
matchedDeclarations( el, doc, def.prop ).forEach( function ( d ) { |
| 4854 |
const cls = classifySelectorPart( d.selectorPart, el ); |
| 4855 |
let src; |
| 4856 |
if ( cls.type === 'gbs' ) { |
| 4857 |
src = { type: 'gbs', class: cls.class, value: d.value }; |
| 4858 |
if ( usageByToken && usageByToken[ cls.class ] !== undefined ) { |
| 4859 |
src.usage = usageByToken[ cls.class ]; |
| 4860 |
} |
| 4861 |
} else if ( cls.type === 'utility' ) { |
| 4862 |
src = { type: 'utility', class: cls.class, value: d.value }; |
| 4863 |
} else { |
| 4864 |
src = { type: 'selector', selector: cls.selector, value: d.value }; |
| 4865 |
} |
| 4866 |
candidates.push( { src, level: d.important ? 3 : 1, specificity: d.specificity, order: d.order } ); |
| 4867 |
} ); |
| 4868 |
candidates.sort( function ( a, b ) { |
| 4869 |
return a.level - b.level || cmpSpec( a.specificity, b.specificity ) || a.order - b.order; |
| 4870 |
} ); |
| 4871 |
return candidates.reverse().map( function ( c ) { |
| 4872 |
return c.src; |
| 4873 |
} ); // winner first |
| 4874 |
} |
| 4875 |
|
| 4876 |
// Per-property { effective, owner, availableSources[] } for ONE block. When the |
| 4877 |
// block is MOUNTED (the real case: the selected block is on-screen), ownership |
| 4878 |
// is resolved from the live CSS cascade (CSSOM) so `owner` can never disagree |
| 4879 |
// with the painted `effective`. When unmounted (off-screen / no canvas — e.g. |
| 4880 |
// jsdom), we degrade to the authored join (attrs + GBS payload), which is |
| 4881 |
// best-effort. `canvas` ({ doc, el }) is an injectable seam for tests. |
| 4882 |
function buildStyleContext( block, pagePayload, usageByToken, canvas ) { |
| 4883 |
if ( ! block ) { |
| 4884 |
return null; |
| 4885 |
} |
| 4886 |
const classes = ( pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object' ) |
| 4887 |
? pagePayload.classes : {}; |
| 4888 |
const gsTokens = gsTokensOf( block ); |
| 4889 |
const attrs = block.attributes || {}; |
| 4890 |
const doc = ( canvas && canvas.doc ) || canvasDoc(); |
| 4891 |
const el = ( canvas && canvas.el !== undefined ) ? canvas.el : elForBlock( doc, block.clientId ); |
| 4892 |
const cs = el ? computedForEl( el, doc ) : computedForBlock( block.clientId ); |
| 4893 |
const properties = {}; |
| 4894 |
if ( el ) { |
| 4895 |
// ROOT PATH — resolve ownership from the live cascade (CSSOM), so a |
| 4896 |
// colour painted by a JIT utility (or the winner of two competing |
| 4897 |
// utilities) is named correctly instead of collapsing to `default`. |
| 4898 |
STYLE_PROPS.forEach( function ( def ) { |
| 4899 |
const sources = resolveCascadeSources( def, attrs, el, doc, usageByToken ); |
| 4900 |
properties[ def.prop ] = { |
| 4901 |
effective: cs ? cs.getPropertyValue( def.prop ) : null, |
| 4902 |
owner: sources.length ? ownerLabel( sources[ 0 ] ) : 'default', |
| 4903 |
availableSources: sources, |
| 4904 |
}; |
| 4905 |
} ); |
| 4906 |
} else { |
| 4907 |
// FALLBACK — block not mounted (no canvas node): the authored join |
| 4908 |
// (attrs + GBS payload) is the best we can do and is flagged best-effort |
| 4909 |
// by the absence of a rendered `effective`. It cannot see utilities. |
| 4910 |
STYLE_PROPS.forEach( function ( def ) { |
| 4911 |
const sources = []; |
| 4912 |
// block_attribute tier (highest specificity) |
| 4913 |
def.attrs.forEach( function ( path ) { |
| 4914 |
const v = deepGet( attrs, path ); |
| 4915 |
if ( v !== undefined && v !== null && v !== '' ) { |
| 4916 |
sources.push( { type: 'block_attribute', path, value: v } ); |
| 4917 |
} |
| 4918 |
} ); |
| 4919 |
// gbs class tier — only classes ACTUALLY on this block, only if they declare it. |
| 4920 |
// `usage` = how many blocks carry this class: >1 means editing its body |
| 4921 |
// restyles those other sections too (the reverse-dependency signal). |
| 4922 |
gsTokens.forEach( function ( token ) { |
| 4923 |
const body = classes[ token ] && classes[ token ].default; |
| 4924 |
if ( ! body || typeof body !== 'object' ) { |
| 4925 |
return; |
| 4926 |
} |
| 4927 |
const v = body[ def.prop ]; |
| 4928 |
if ( v !== undefined && v !== null && v !== '' ) { |
| 4929 |
sources.push( { type: 'gbs', class: token, value: v, usage: usageByToken ? usageByToken[ token ] : undefined } ); |
| 4930 |
} |
| 4931 |
} ); |
| 4932 |
const owner = sources.length === 0 |
| 4933 |
? 'default' |
| 4934 |
: ( sources[ 0 ].type === 'block_attribute' |
| 4935 |
? 'block_attribute:' + sources[ 0 ].path |
| 4936 |
: 'gbs:' + sources[ 0 ].class ); |
| 4937 |
properties[ def.prop ] = { |
| 4938 |
effective: null, |
| 4939 |
owner, |
| 4940 |
availableSources: sources, |
| 4941 |
}; |
| 4942 |
} ); |
| 4943 |
} |
| 4944 |
// Classes on THIS block that also style other sections — edit their body |
| 4945 |
// and every listed block moves with it. The model surfaces this to the user |
| 4946 |
// (or restyles this block with utilities instead — see the doctrine). |
| 4947 |
const sharedClasses = usageByToken |
| 4948 |
? gsTokens.filter( function ( t ) { |
| 4949 |
return ( usageByToken[ t ] || 1 ) > 1; |
| 4950 |
} ) |
| 4951 |
.map( function ( t ) { |
| 4952 |
return { class: t, used_by: usageByToken[ t ] }; |
| 4953 |
} ) |
| 4954 |
: []; |
| 4955 |
return { |
| 4956 |
client_id: block.clientId, |
| 4957 |
gbs_classes: gsTokens, |
| 4958 |
properties, |
| 4959 |
shared_gbs_classes: sharedClasses, |
| 4960 |
}; |
| 4961 |
} |
| 4962 |
|
| 4963 |
// ── get-styles ─────────────────────────────────────────────────────────── |
| 4964 |
async function handleGetStyles( args ) { |
| 4965 |
const scope = args && args.scope === 'global' ? 'global' : 'page'; |
| 4966 |
if ( scope === 'page' ) { |
| 4967 |
const sel = editorSelect(); |
| 4968 |
const postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0; |
| 4969 |
if ( ! postId ) { |
| 4970 |
return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' }; |
| 4971 |
} |
| 4972 |
try { |
| 4973 |
const pres = await apiFetch( { path: NS + '/save?scope=page&post_id=' + postId } ); |
| 4974 |
const pp = ( pres && pres.payload && typeof pres.payload === 'object' ) ? pres.payload : {}; |
| 4975 |
const out = { scope: 'page', post_id: postId, buckets: bucketsOf( pp ), payload: pp }; |
| 4976 |
// STYLE CONTEXT (ownership) for a target block — the agent reads |
| 4977 |
// this BEFORE a styling edit so it updates the existing owner |
| 4978 |
// instead of guessing/stacking. Best-effort: a missing block / |
| 4979 |
// unmounted node simply omits styleContext (never blocks the read). |
| 4980 |
const clientId = args && typeof args.client_id === 'string' ? args.client_id : null; |
| 4981 |
if ( clientId && sel && sel.getBlock ) { |
| 4982 |
const blk = sel.getBlock( clientId ); |
| 4983 |
// Reverse-dependency counts for the block's gs- tokens, so the |
| 4984 |
// model sees "shared by N" BEFORE it picks an owner to edit. |
| 4985 |
const usageByToken = {}; |
| 4986 |
( blk && blk.attributes && typeof blk.attributes.className === 'string' |
| 4987 |
? blk.attributes.className.trim().split( /\s+/ ) : [] ) |
| 4988 |
.filter( function ( t ) { |
| 4989 |
return t.indexOf( 'gs-' ) === 0; |
| 4990 |
} ) |
| 4991 |
.forEach( function ( t ) { |
| 4992 |
usageByToken[ t ] = classUsage( t ); |
| 4993 |
} ); |
| 4994 |
const ctx = buildStyleContext( blk, pp, usageByToken ); |
| 4995 |
if ( ctx ) { |
| 4996 |
out.styleContext = ctx; |
| 4997 |
} |
| 4998 |
} |
| 4999 |
return { success: true, data: out }; |
| 5000 |
} catch ( e ) { |
| 5001 |
return { success: false, error: 'page_read_failed: ' + String( e && e.message ? e.message : e ) }; |
| 5002 |
} |
| 5003 |
} |
| 5004 |
try { |
| 5005 |
const res = await apiFetch( { path: NS + '/user-css' } ); |
| 5006 |
const gp = ( res && res.payload && typeof res.payload === 'object' ) ? res.payload : {}; |
| 5007 |
return { success: true, data: { scope: 'global', buckets: bucketsOf( gp ), payload: gp } }; |
| 5008 |
} catch ( e ) { |
| 5009 |
return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) }; |
| 5010 |
} |
| 5011 |
} |
| 5012 |
|
| 5013 |
// ── set-styles ─────────────────────────────────────────────────────────── |
| 5014 |
// GLOBAL (chrome) scope ONLY. The former scope:'page' write — an IMMEDIATE, |
| 5015 |
// irreversible REST write to this post's `spectra_blocks_pro_gs_user_css` meta |
| 5016 |
// — was REMOVED: it persisted a per-block style edit to the DB even if the user |
| 5017 |
// never Saved (no undo), and editing a SHARED gs- class body silently restyled |
| 5018 |
// every section carrying it. All per-block styling now goes through |
| 5019 |
// editor/apply-change utility classNames (editor state, committed on Save). This |
| 5020 |
// handler still rejects scope:'page' defensively so a stale/forked brain bundle |
| 5021 |
// can never resurrect the silent DB write. |
| 5022 |
async function handleSetStyles( args ) { |
| 5023 |
const scope = args && args.scope === 'global' ? 'global' : null; |
| 5024 |
const incoming = args && args.payload; |
| 5025 |
if ( args && args.scope === 'page' ) { |
| 5026 |
return { success: false, error: 'unsupported_scope: per-page style writes were removed (they wrote the DB before Save and restyled shared classes). Change per-block styling with a utility className via apply_change instead. set_styles is header/footer chrome (scope:"global") only.' }; |
| 5027 |
} |
| 5028 |
if ( scope === null ) { |
| 5029 |
return { success: false, error: 'invalid_input: scope must be "global" (per-block styling uses apply_change utilities)' }; |
| 5030 |
} |
| 5031 |
if ( ! incoming || typeof incoming !== 'object' || Array.isArray( incoming ) ) { |
| 5032 |
return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' }; |
| 5033 |
} |
| 5034 |
if ( ! bucketsOf( incoming ).length ) { |
| 5035 |
return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' }; |
| 5036 |
} |
| 5037 |
|
| 5038 |
// scope === 'global' — read-modify-write the option (immediate, site-wide). |
| 5039 |
let existingGlobal; |
| 5040 |
try { |
| 5041 |
// eslint-disable-next-line no-redeclare, no-var |
| 5042 |
var g = await apiFetch( { path: NS + '/user-css' } ); |
| 5043 |
existingGlobal = ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {}; |
| 5044 |
} catch ( e ) { |
| 5045 |
return { success: false, error: 'global_read_failed: ' + String( e && e.message ? e.message : e ) }; |
| 5046 |
} |
| 5047 |
const mergedGlobal = mergePayload( existingGlobal, incoming ); |
| 5048 |
try { |
| 5049 |
// /sitewide replaces non-class buckets wholesale → send the FULL merged |
| 5050 |
// payload so the write equals the merged state (chrome/user classes kept). |
| 5051 |
await apiFetch( { path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } } ); |
| 5052 |
} catch ( e ) { |
| 5053 |
return { success: false, error: 'global_write_failed: ' + String( e && e.message ? e.message : e ) }; |
| 5054 |
} |
| 5055 |
try { |
| 5056 |
const rg = await apiFetch( { path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } } ); |
| 5057 |
// Append-last override so the live global paint wins source-order ties |
| 5058 |
// (deletions converge on reload, consistent with the live-JIT model). |
| 5059 |
injectCss( 'zipwp-gbs-live-global', rg && rg.css ); |
| 5060 |
} catch ( e ) { |
| 5061 |
return { success: false, error: 'render_failed: ' + String( e && e.message ? e.message : e ) }; |
| 5062 |
} |
| 5063 |
return { |
| 5064 |
success: true, |
| 5065 |
data: { |
| 5066 |
scope: 'global', |
| 5067 |
buckets: bucketsOf( incoming ), |
| 5068 |
note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).', |
| 5069 |
}, |
| 5070 |
}; |
| 5071 |
} |
| 5072 |
|
| 5073 |
function initHandler() { |
| 5074 |
if ( window.zipwpMcp && window.zipwpMcp.registerTool ) { |
| 5075 |
window.zipwpMcp.registerTool( 'editor/get-styles', async function ( args ) { |
| 5076 |
return handleGetStyles( args ); |
| 5077 |
}, { previewMode: 'client' } ); |
| 5078 |
window.zipwpMcp.registerTool( 'editor/set-styles', async function ( args ) { |
| 5079 |
return handleSetStyles( args ); |
| 5080 |
}, { previewMode: 'client' } ); |
| 5081 |
} else { |
| 5082 |
setTimeout( initHandler, 100 ); |
| 5083 |
} |
| 5084 |
} |
| 5085 |
initHandler(); |
| 5086 |
|
| 5087 |
// Test-only surface (Node/CommonJS) — inert in the browser bundle. |
| 5088 |
if ( typeof module !== 'undefined' && module.exports ) { |
| 5089 |
module.exports = { |
| 5090 |
handleGetStyles, |
| 5091 |
handleSetStyles, |
| 5092 |
mergePayload, |
| 5093 |
// Ownership resolver (per-property effective/owner/availableSources). |
| 5094 |
// Exported PURE so a unit test locks the join, not a reimplementation. |
| 5095 |
buildStyleContext, |
| 5096 |
}; |
| 5097 |
} |
| 5098 |
}() ); |
| 5099 |
|
| 5100 |
|
| 5101 |
/** |
| 5102 |
* Block Context Picker |
| 5103 |
* |
| 5104 |
* Adds a "+" pin button on block hover in Gutenberg editor. |
| 5105 |
* Selected blocks are sent as context to the chat assistant. |
| 5106 |
* Supports both classic and iframed block editor (WP 6.3+). |
| 5107 |
* |
| 5108 |
* @since x.x.x |
| 5109 |
*/ |
| 5110 |
|
| 5111 |
( function() { |
| 5112 |
'use strict'; |
| 5113 |
|
| 5114 |
/** SVG for the "+" (add) icon */ |
| 5115 |
const ICON_ADD = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'; |
| 5116 |
|
| 5117 |
/** SVG for the checkmark (pinned) icon */ |
| 5118 |
const ICON_CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; |
| 5119 |
|
| 5120 |
function BlockContextPicker() { |
| 5121 |
/** @type {Map<string, Object>} clientId → serialized block data */ |
| 5122 |
this.contextBlocks = new Map(); |
| 5123 |
|
| 5124 |
/** @type {HTMLElement|null} Single floating pin button */ |
| 5125 |
this.pinButton = null; |
| 5126 |
|
| 5127 |
/** @type {string|null} clientId of the block currently being hovered */ |
| 5128 |
this.hoveredClientId = null; |
| 5129 |
|
| 5130 |
/** @type {boolean} Whether the picker is currently active */ |
| 5131 |
this.active = false; |
| 5132 |
|
| 5133 |
/** @type {Function|null} wp.data.subscribe unsubscribe handle */ |
| 5134 |
this._unsubscribeBlockDeletion = null; |
| 5135 |
|
| 5136 |
/** @type {Document} The document where blocks live (parent or iframe) */ |
| 5137 |
this._editorDocument = document; |
| 5138 |
|
| 5139 |
/** @type {number} Last known block count for quick deletion check */ |
| 5140 |
this._lastBlockCount = 0; |
| 5141 |
|
| 5142 |
/** @type {Function|null} Bound handler references for cleanup */ |
| 5143 |
const self = this; |
| 5144 |
this._onMouseOver = function( e ) { |
| 5145 |
self._handleMouseOver( e ); |
| 5146 |
}; |
| 5147 |
this._onMouseOut = function( e ) { |
| 5148 |
self._handleMouseOut( e ); |
| 5149 |
}; |
| 5150 |
this._onPinClick = function( e ) { |
| 5151 |
self._handlePinClick( e ); |
| 5152 |
}; |
| 5153 |
} |
| 5154 |
|
| 5155 |
/** |
| 5156 |
* Enable the picker — called when sidebar opens. |
| 5157 |
* |
| 5158 |
* @since x.x.x |
| 5159 |
*/ |
| 5160 |
BlockContextPicker.prototype.enable = function() { |
| 5161 |
if ( this.active ) { |
| 5162 |
return; |
| 5163 |
} |
| 5164 |
|
| 5165 |
if ( ! window.wp || ! window.wp.data || ! window.wp.data.select( 'core/block-editor' ) ) { |
| 5166 |
return; |
| 5167 |
} |
| 5168 |
|
| 5169 |
this.active = true; |
| 5170 |
this._resolveEditorDocument(); |
| 5171 |
this._createPinButton(); |
| 5172 |
this._bindEditorEvents(); |
| 5173 |
this._watchBlockDeletion(); |
| 5174 |
}; |
| 5175 |
|
| 5176 |
/** |
| 5177 |
* Disable the picker — called when sidebar closes. |
| 5178 |
* |
| 5179 |
* @since x.x.x |
| 5180 |
*/ |
| 5181 |
BlockContextPicker.prototype.disable = function() { |
| 5182 |
if ( ! this.active ) { |
| 5183 |
return; |
| 5184 |
} |
| 5185 |
|
| 5186 |
this.active = false; |
| 5187 |
this._removePinButton(); |
| 5188 |
this._unbindEditorEvents(); |
| 5189 |
this._unwatchBlockDeletion(); |
| 5190 |
this.hoveredClientId = null; |
| 5191 |
}; |
| 5192 |
|
| 5193 |
/** |
| 5194 |
* Clear all pinned context blocks. |
| 5195 |
* |
| 5196 |
* @since x.x.x |
| 5197 |
*/ |
| 5198 |
BlockContextPicker.prototype.clear = function() { |
| 5199 |
this.contextBlocks.clear(); |
| 5200 |
this.hoveredClientId = null; |
| 5201 |
this._removeAllHighlights(); |
| 5202 |
this._updateBadge(); |
| 5203 |
}; |
| 5204 |
|
| 5205 |
/** |
| 5206 |
* Get all context blocks as an array for the chat context payload. |
| 5207 |
* |
| 5208 |
* @since x.x.x |
| 5209 |
* @return {Array<Object>} |
| 5210 |
*/ |
| 5211 |
BlockContextPicker.prototype.getContextBlocks = function() { |
| 5212 |
return Array.from( this.contextBlocks.values() ); |
| 5213 |
}; |
| 5214 |
|
| 5215 |
/** |
| 5216 |
* Check if any blocks are pinned. |
| 5217 |
* |
| 5218 |
* @since x.x.x |
| 5219 |
* @return {boolean} |
| 5220 |
*/ |
| 5221 |
BlockContextPicker.prototype.hasContextBlocks = function() { |
| 5222 |
return this.contextBlocks.size > 0; |
| 5223 |
}; |
| 5224 |
|
| 5225 |
// ── Editor document resolution (iframe support) ───────────────── |
| 5226 |
|
| 5227 |
/** |
| 5228 |
* Detect whether the block editor uses an iframe canvas (WP 6.3+). |
| 5229 |
* |
| 5230 |
* @since x.x.x |
| 5231 |
*/ |
| 5232 |
BlockContextPicker.prototype._resolveEditorDocument = function() { |
| 5233 |
const editorIframe = document.querySelector( 'iframe[name="editor-canvas"]' ); |
| 5234 |
if ( editorIframe && editorIframe.contentDocument ) { |
| 5235 |
this._editorDocument = editorIframe.contentDocument; |
| 5236 |
} else { |
| 5237 |
this._editorDocument = document; |
| 5238 |
} |
| 5239 |
}; |
| 5240 |
|
| 5241 |
// ── DOM — pin button ──────────────────────────────────────────── |
| 5242 |
|
| 5243 |
/** |
| 5244 |
* Create the floating pin button element. |
| 5245 |
* |
| 5246 |
* @since x.x.x |
| 5247 |
*/ |
| 5248 |
BlockContextPicker.prototype._createPinButton = function() { |
| 5249 |
if ( this.pinButton ) { |
| 5250 |
return; |
| 5251 |
} |
| 5252 |
|
| 5253 |
const doc = this._editorDocument; |
| 5254 |
const btn = doc.createElement( 'button' ); |
| 5255 |
btn.className = 'zipwp-context-pin'; |
| 5256 |
btn.type = 'button'; |
| 5257 |
btn.setAttribute( 'aria-label', 'Pin block to chat context' ); |
| 5258 |
btn.innerHTML = ICON_ADD; |
| 5259 |
|
| 5260 |
btn.addEventListener( 'click', this._onPinClick ); |
| 5261 |
doc.body.appendChild( btn ); |
| 5262 |
this.pinButton = btn; |
| 5263 |
|
| 5264 |
// Inject styles into iframe editor if needed |
| 5265 |
if ( doc !== document ) { |
| 5266 |
this._injectIframeStyles( doc ); |
| 5267 |
} |
| 5268 |
}; |
| 5269 |
|
| 5270 |
/** |
| 5271 |
* Inject context picker CSS into the editor iframe document. |
| 5272 |
* |
| 5273 |
* @since x.x.x |
| 5274 |
* @param {Document} doc |
| 5275 |
*/ |
| 5276 |
BlockContextPicker.prototype._injectIframeStyles = function( doc ) { |
| 5277 |
if ( doc.getElementById( 'zipwp-context-picker-styles' ) ) { |
| 5278 |
return; |
| 5279 |
} |
| 5280 |
|
| 5281 |
const style = doc.createElement( 'style' ); |
| 5282 |
style.id = 'zipwp-context-picker-styles'; |
| 5283 |
style.textContent = |
| 5284 |
'.zipwp-context-pin{display:none;position:absolute;z-index:100000;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:6px;background:#6366f1;color:#fff;cursor:pointer;box-shadow:0 2px 8px rgba(99,102,241,.35);transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}' + |
| 5285 |
'.zipwp-context-pin:hover{background:#4f46e5;transform:scale(1.1);box-shadow:0 4px 12px rgba(99,102,241,.45)}' + |
| 5286 |
'.zipwp-context-pin svg{width:16px;height:16px;display:block;pointer-events:none}' + |
| 5287 |
'.zipwp-context-pin--active{background:#16a34a}' + |
| 5288 |
'.zipwp-context-pin--active:hover{background:#dc2626}' + |
| 5289 |
'.zipwp-context-selected{box-shadow:0 0 0 2px #6366f1 !important;border-radius:2px}'; |
| 5290 |
doc.head.appendChild( style ); |
| 5291 |
}; |
| 5292 |
|
| 5293 |
/** |
| 5294 |
* Remove the pin button from DOM. |
| 5295 |
* |
| 5296 |
* @since x.x.x |
| 5297 |
*/ |
| 5298 |
BlockContextPicker.prototype._removePinButton = function() { |
| 5299 |
if ( this.pinButton ) { |
| 5300 |
this.pinButton.removeEventListener( 'click', this._onPinClick ); |
| 5301 |
this.pinButton.remove(); |
| 5302 |
this.pinButton = null; |
| 5303 |
} |
| 5304 |
}; |
| 5305 |
|
| 5306 |
/** |
| 5307 |
* Position the pin button next to the hovered block. |
| 5308 |
* |
| 5309 |
* @since x.x.x |
| 5310 |
* @param {HTMLElement} blockEl |
| 5311 |
*/ |
| 5312 |
BlockContextPicker.prototype._positionPinButton = function( blockEl ) { |
| 5313 |
if ( ! this.pinButton ) { |
| 5314 |
return; |
| 5315 |
} |
| 5316 |
|
| 5317 |
const doc = this._editorDocument; |
| 5318 |
const rect = blockEl.getBoundingClientRect(); |
| 5319 |
const scrollTop = doc.defaultView.scrollY || doc.documentElement.scrollTop; |
| 5320 |
const scrollLeft = doc.defaultView.scrollX || doc.documentElement.scrollLeft; |
| 5321 |
|
| 5322 |
this.pinButton.style.top = ( rect.top + scrollTop + 4 ) + 'px'; |
| 5323 |
this.pinButton.style.left = ( rect.right + scrollLeft - 36 ) + 'px'; |
| 5324 |
this.pinButton.style.display = 'flex'; |
| 5325 |
|
| 5326 |
// Update icon for pinned state |
| 5327 |
const clientId = blockEl.dataset.block; |
| 5328 |
if ( clientId && this.contextBlocks.has( clientId ) ) { |
| 5329 |
this.pinButton.classList.add( 'zipwp-context-pin--active' ); |
| 5330 |
this.pinButton.innerHTML = ICON_CHECK; |
| 5331 |
this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' ); |
| 5332 |
} else { |
| 5333 |
this.pinButton.classList.remove( 'zipwp-context-pin--active' ); |
| 5334 |
this.pinButton.innerHTML = ICON_ADD; |
| 5335 |
this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' ); |
| 5336 |
} |
| 5337 |
}; |
| 5338 |
|
| 5339 |
/** |
| 5340 |
* Hide the pin button. |
| 5341 |
* |
| 5342 |
* @since x.x.x |
| 5343 |
*/ |
| 5344 |
BlockContextPicker.prototype._hidePinButton = function() { |
| 5345 |
if ( this.pinButton ) { |
| 5346 |
this.pinButton.style.display = 'none'; |
| 5347 |
} |
| 5348 |
}; |
| 5349 |
|
| 5350 |
// ── Events — hover delegation ─────────────────────────────────── |
| 5351 |
|
| 5352 |
/** |
| 5353 |
* Bind mouseover/mouseout on the editor canvas via delegation. |
| 5354 |
* |
| 5355 |
* @since x.x.x |
| 5356 |
*/ |
| 5357 |
BlockContextPicker.prototype._bindEditorEvents = function() { |
| 5358 |
const doc = this._editorDocument; |
| 5359 |
doc.addEventListener( 'mouseover', this._onMouseOver, true ); |
| 5360 |
doc.addEventListener( 'mouseout', this._onMouseOut, true ); |
| 5361 |
}; |
| 5362 |
|
| 5363 |
/** |
| 5364 |
* Unbind editor hover events. |
| 5365 |
* |
| 5366 |
* @since x.x.x |
| 5367 |
*/ |
| 5368 |
BlockContextPicker.prototype._unbindEditorEvents = function() { |
| 5369 |
const doc = this._editorDocument; |
| 5370 |
doc.removeEventListener( 'mouseover', this._onMouseOver, true ); |
| 5371 |
doc.removeEventListener( 'mouseout', this._onMouseOut, true ); |
| 5372 |
}; |
| 5373 |
|
| 5374 |
/** |
| 5375 |
* Handle mouseover — find the closest block wrapper. |
| 5376 |
* |
| 5377 |
* @since x.x.x |
| 5378 |
* @param {MouseEvent} e |
| 5379 |
*/ |
| 5380 |
BlockContextPicker.prototype._handleMouseOver = function( e ) { |
| 5381 |
if ( ! this.active ) { |
| 5382 |
return; |
| 5383 |
} |
| 5384 |
|
| 5385 |
if ( this.pinButton && this.pinButton.contains( e.target ) ) { |
| 5386 |
return; |
| 5387 |
} |
| 5388 |
|
| 5389 |
const blockEl = e.target.closest( '[data-block]' ); |
| 5390 |
if ( ! blockEl ) { |
| 5391 |
return; |
| 5392 |
} |
| 5393 |
|
| 5394 |
const clientId = blockEl.dataset.block; |
| 5395 |
if ( ! clientId || clientId === this.hoveredClientId ) { |
| 5396 |
return; |
| 5397 |
} |
| 5398 |
|
| 5399 |
this.hoveredClientId = clientId; |
| 5400 |
this._positionPinButton( blockEl ); |
| 5401 |
}; |
| 5402 |
|
| 5403 |
/** |
| 5404 |
* Handle mouseout — hide pin when cursor leaves all blocks. |
| 5405 |
* |
| 5406 |
* @since x.x.x |
| 5407 |
* @param {MouseEvent} e |
| 5408 |
*/ |
| 5409 |
BlockContextPicker.prototype._handleMouseOut = function( e ) { |
| 5410 |
if ( ! this.active ) { |
| 5411 |
return; |
| 5412 |
} |
| 5413 |
|
| 5414 |
if ( this.pinButton && this.pinButton.contains( e.relatedTarget ) ) { |
| 5415 |
return; |
| 5416 |
} |
| 5417 |
|
| 5418 |
const relatedBlock = e.relatedTarget && e.relatedTarget.closest |
| 5419 |
? e.relatedTarget.closest( '[data-block]' ) |
| 5420 |
: null; |
| 5421 |
if ( relatedBlock ) { |
| 5422 |
return; |
| 5423 |
} |
| 5424 |
|
| 5425 |
this.hoveredClientId = null; |
| 5426 |
this._hidePinButton(); |
| 5427 |
}; |
| 5428 |
|
| 5429 |
/** |
| 5430 |
* Handle click on the pin button — toggle block in context. |
| 5431 |
* |
| 5432 |
* @since x.x.x |
| 5433 |
* @param {MouseEvent} e |
| 5434 |
*/ |
| 5435 |
BlockContextPicker.prototype._handlePinClick = function( e ) { |
| 5436 |
e.preventDefault(); |
| 5437 |
e.stopPropagation(); |
| 5438 |
|
| 5439 |
const clientId = this.hoveredClientId; |
| 5440 |
if ( ! clientId ) { |
| 5441 |
return; |
| 5442 |
} |
| 5443 |
|
| 5444 |
if ( this.contextBlocks.has( clientId ) ) { |
| 5445 |
// Unpin |
| 5446 |
this.contextBlocks.delete( clientId ); |
| 5447 |
this._removeHighlight( clientId ); |
| 5448 |
if ( this.pinButton ) { |
| 5449 |
this.pinButton.classList.remove( 'zipwp-context-pin--active' ); |
| 5450 |
this.pinButton.innerHTML = ICON_ADD; |
| 5451 |
this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' ); |
| 5452 |
} |
| 5453 |
} else { |
| 5454 |
// Pin |
| 5455 |
const blockData = this._serializeBlockForContext( clientId ); |
| 5456 |
if ( blockData ) { |
| 5457 |
this.contextBlocks.set( clientId, blockData ); |
| 5458 |
this._addHighlight( clientId ); |
| 5459 |
if ( this.pinButton ) { |
| 5460 |
this.pinButton.classList.add( 'zipwp-context-pin--active' ); |
| 5461 |
this.pinButton.innerHTML = ICON_CHECK; |
| 5462 |
this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' ); |
| 5463 |
} |
| 5464 |
} |
| 5465 |
} |
| 5466 |
|
| 5467 |
this._updateBadge(); |
| 5468 |
this._notifyContextChanged(); |
| 5469 |
}; |
| 5470 |
|
| 5471 |
// ── Bridge notification ───────────────────────────────────────── |
| 5472 |
|
| 5473 |
/** |
| 5474 |
* Notify the React app that context blocks changed. |
| 5475 |
* Emits event on the React DirectBridge so components can react. |
| 5476 |
* |
| 5477 |
* @since x.x.x |
| 5478 |
*/ |
| 5479 |
BlockContextPicker.prototype._notifyContextChanged = function() { |
| 5480 |
const appBridge = window.zipwpMcpAppBridge; |
| 5481 |
if ( appBridge && appBridge.emit ) { |
| 5482 |
appBridge.emit( 'context_blocks_changed', { |
| 5483 |
context_blocks: this.getContextBlocks(), |
| 5484 |
count: this.contextBlocks.size, |
| 5485 |
} ); |
| 5486 |
} |
| 5487 |
}; |
| 5488 |
|
| 5489 |
// ── Block serialization ───────────────────────────────────────── |
| 5490 |
|
| 5491 |
/** |
| 5492 |
* Serialize a block for chat context. |
| 5493 |
* |
| 5494 |
* @since x.x.x |
| 5495 |
* @param {string} clientId |
| 5496 |
* @return {Object|null} |
| 5497 |
*/ |
| 5498 |
BlockContextPicker.prototype._serializeBlockForContext = function( clientId ) { |
| 5499 |
if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) { |
| 5500 |
return null; |
| 5501 |
} |
| 5502 |
|
| 5503 |
const blockEditorSelect = window.wp.data.select( 'core/block-editor' ); |
| 5504 |
const block = blockEditorSelect.getBlock( clientId ); |
| 5505 |
if ( ! block ) { |
| 5506 |
return null; |
| 5507 |
} |
| 5508 |
|
| 5509 |
let blocksHtml = ''; |
| 5510 |
try { |
| 5511 |
blocksHtml = window.wp.blocks.serialize( [ block ] ); |
| 5512 |
const bridge = window.zipwpMcpBridge; |
| 5513 |
if ( bridge && bridge.fixUnicodeEscapes ) { |
| 5514 |
blocksHtml = bridge.fixUnicodeEscapes( blocksHtml ); |
| 5515 |
} |
| 5516 |
} catch ( e ) { |
| 5517 |
console.warn( 'BlockContextPicker: Failed to serialize block', e ); // eslint-disable-line no-console -- intentional error surfacing |
| 5518 |
} |
| 5519 |
|
| 5520 |
const rootClientId = blockEditorSelect.getBlockRootClientId( clientId ); |
| 5521 |
const blockIndex = blockEditorSelect.getBlockIndex( clientId ); |
| 5522 |
|
| 5523 |
return { |
| 5524 |
clientId: block.clientId, |
| 5525 |
name: block.name, |
| 5526 |
blocks_html: blocksHtml, |
| 5527 |
block_index: blockIndex, |
| 5528 |
root_client_id: rootClientId || null, |
| 5529 |
}; |
| 5530 |
}; |
| 5531 |
|
| 5532 |
// ── Visual feedback — block highlights ────────────────────────── |
| 5533 |
|
| 5534 |
/** |
| 5535 |
* @param clientId |
| 5536 |
* @since x.x.x |
| 5537 |
*/ |
| 5538 |
BlockContextPicker.prototype._addHighlight = function( clientId ) { |
| 5539 |
const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' ); |
| 5540 |
if ( blockEl ) { |
| 5541 |
blockEl.classList.add( 'zipwp-context-selected' ); |
| 5542 |
} |
| 5543 |
}; |
| 5544 |
|
| 5545 |
/** |
| 5546 |
* @param clientId |
| 5547 |
* @since x.x.x |
| 5548 |
*/ |
| 5549 |
BlockContextPicker.prototype._removeHighlight = function( clientId ) { |
| 5550 |
const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' ); |
| 5551 |
if ( blockEl ) { |
| 5552 |
blockEl.classList.remove( 'zipwp-context-selected' ); |
| 5553 |
} |
| 5554 |
}; |
| 5555 |
|
| 5556 |
/** |
| 5557 |
* @since x.x.x |
| 5558 |
*/ |
| 5559 |
BlockContextPicker.prototype._removeAllHighlights = function() { |
| 5560 |
const els = this._editorDocument.querySelectorAll( '.zipwp-context-selected' ); |
| 5561 |
for ( let i = 0; i < els.length; i++ ) { |
| 5562 |
els[ i ].classList.remove( 'zipwp-context-selected' ); |
| 5563 |
} |
| 5564 |
}; |
| 5565 |
|
| 5566 |
// ── Badge on FAB trigger button ───────────────────────────────── |
| 5567 |
|
| 5568 |
/** |
| 5569 |
* @since x.x.x |
| 5570 |
*/ |
| 5571 |
BlockContextPicker.prototype._updateBadge = function() { |
| 5572 |
const trigger = document.getElementById( 'zip-ai-floating-trigger' ); |
| 5573 |
if ( ! trigger ) { |
| 5574 |
return; |
| 5575 |
} |
| 5576 |
|
| 5577 |
let badge = trigger.querySelector( '.zipwp-context-badge' ); |
| 5578 |
const count = this.contextBlocks.size; |
| 5579 |
|
| 5580 |
if ( count === 0 ) { |
| 5581 |
if ( badge ) { |
| 5582 |
badge.remove(); |
| 5583 |
} |
| 5584 |
return; |
| 5585 |
} |
| 5586 |
|
| 5587 |
if ( ! badge ) { |
| 5588 |
badge = document.createElement( 'span' ); |
| 5589 |
badge.className = 'zipwp-context-badge'; |
| 5590 |
trigger.appendChild( badge ); |
| 5591 |
} |
| 5592 |
|
| 5593 |
badge.textContent = count; |
| 5594 |
}; |
| 5595 |
|
| 5596 |
// ── Block deletion watcher ────────────────────────────────────── |
| 5597 |
|
| 5598 |
/** |
| 5599 |
* @since x.x.x |
| 5600 |
*/ |
| 5601 |
BlockContextPicker.prototype._watchBlockDeletion = function() { |
| 5602 |
if ( ! window.wp || ! window.wp.data ) { |
| 5603 |
return; |
| 5604 |
} |
| 5605 |
|
| 5606 |
const self = this; |
| 5607 |
const subscribe = window.wp.data.subscribe; |
| 5608 |
const blockEditorSelect = window.wp.data.select( 'core/block-editor' ); |
| 5609 |
|
| 5610 |
this._lastBlockCount = blockEditorSelect.getGlobalBlockCount |
| 5611 |
? blockEditorSelect.getGlobalBlockCount() |
| 5612 |
: blockEditorSelect.getBlocks().length; |
| 5613 |
|
| 5614 |
this._unsubscribeBlockDeletion = subscribe( function() { |
| 5615 |
if ( self.contextBlocks.size === 0 ) { |
| 5616 |
return; |
| 5617 |
} |
| 5618 |
|
| 5619 |
const currentCount = blockEditorSelect.getGlobalBlockCount |
| 5620 |
? blockEditorSelect.getGlobalBlockCount() |
| 5621 |
: blockEditorSelect.getBlocks().length; |
| 5622 |
|
| 5623 |
if ( currentCount >= self._lastBlockCount ) { |
| 5624 |
self._lastBlockCount = currentCount; |
| 5625 |
return; |
| 5626 |
} |
| 5627 |
|
| 5628 |
self._lastBlockCount = currentCount; |
| 5629 |
|
| 5630 |
const currentBlockIds = self._getAllBlockClientIds(); |
| 5631 |
let changed = false; |
| 5632 |
|
| 5633 |
// Use Array.from to avoid iterator issues in older environments |
| 5634 |
const pinnedIds = Array.from( self.contextBlocks.keys() ); |
| 5635 |
for ( let i = 0; i < pinnedIds.length; i++ ) { |
| 5636 |
if ( ! currentBlockIds.has( pinnedIds[ i ] ) ) { |
| 5637 |
self.contextBlocks.delete( pinnedIds[ i ] ); |
| 5638 |
self._removeHighlight( pinnedIds[ i ] ); |
| 5639 |
changed = true; |
| 5640 |
} |
| 5641 |
} |
| 5642 |
|
| 5643 |
if ( changed ) { |
| 5644 |
self._updateBadge(); |
| 5645 |
self._notifyContextChanged(); |
| 5646 |
} |
| 5647 |
} ); |
| 5648 |
}; |
| 5649 |
|
| 5650 |
/** |
| 5651 |
* @since x.x.x |
| 5652 |
*/ |
| 5653 |
BlockContextPicker.prototype._unwatchBlockDeletion = function() { |
| 5654 |
if ( this._unsubscribeBlockDeletion ) { |
| 5655 |
this._unsubscribeBlockDeletion(); |
| 5656 |
this._unsubscribeBlockDeletion = null; |
| 5657 |
} |
| 5658 |
}; |
| 5659 |
|
| 5660 |
/** |
| 5661 |
* Get a Set of all block clientIds in the editor. |
| 5662 |
* |
| 5663 |
* @since x.x.x |
| 5664 |
* @return {Set<string>} |
| 5665 |
*/ |
| 5666 |
BlockContextPicker.prototype._getAllBlockClientIds = function() { |
| 5667 |
const ids = new Set(); |
| 5668 |
if ( ! window.wp || ! window.wp.data ) { |
| 5669 |
return ids; |
| 5670 |
} |
| 5671 |
|
| 5672 |
const blockEditorSelect = window.wp.data.select( 'core/block-editor' ); |
| 5673 |
if ( ! blockEditorSelect ) { |
| 5674 |
return ids; |
| 5675 |
} |
| 5676 |
|
| 5677 |
const collectIds = function( blocks ) { |
| 5678 |
for ( let i = 0; i < blocks.length; i++ ) { |
| 5679 |
ids.add( blocks[ i ].clientId ); |
| 5680 |
if ( blocks[ i ].innerBlocks && blocks[ i ].innerBlocks.length > 0 ) { |
| 5681 |
collectIds( blocks[ i ].innerBlocks ); |
| 5682 |
} |
| 5683 |
} |
| 5684 |
}; |
| 5685 |
|
| 5686 |
collectIds( blockEditorSelect.getBlocks() ); |
| 5687 |
return ids; |
| 5688 |
}; |
| 5689 |
|
| 5690 |
// Expose singleton on window |
| 5691 |
window.zipwpMcpContextPicker = new BlockContextPicker(); |
| 5692 |
}() ); |
| 5693 |
|
| 5694 |
|
| 5695 |
/** |
| 5696 |
* Popover Drag Manager |
| 5697 |
* |
| 5698 |
* Handles drag-to-reposition for the popover panel mode. |
| 5699 |
* Position persists in localStorage so it stays where the user left it. |
| 5700 |
* |
| 5701 |
* Used by WPBridgeHost — not standalone. |
| 5702 |
*/ |
| 5703 |
|
| 5704 |
( function() { |
| 5705 |
'use strict'; |
| 5706 |
|
| 5707 |
// Keys + geometry come from the ZIPWP_LAYOUT SSOT (loaded first); the |
| 5708 |
// fallbacks keep this resilient if that global is ever absent. |
| 5709 |
const L = ( typeof window !== 'undefined' && window.ZIPWP_LAYOUT ) || {}; |
| 5710 |
const LKEYS = L.keys || {}; |
| 5711 |
const LPOP = L.popover || {}; |
| 5712 |
const STORAGE_KEY = LKEYS.popoverPosition || 'zipwp-popover-position'; |
| 5713 |
const SIZE_STORAGE_KEY = LKEYS.popoverSize || 'zipwp-popover-size'; |
| 5714 |
const CONTAINER_ID = 'zip-ai-assistant-container'; |
| 5715 |
const DRAG_HANDLE_SELECTOR = '[data-popover-drag]'; |
| 5716 |
const RESIZE_HANDLE_SELECTOR = '[data-popover-resize]'; |
| 5717 |
const INTERACTIVE_SELECTOR = 'button, input, textarea, a, [role="menuitem"], [data-radix-collection-item]'; |
| 5718 |
const DRAG_THRESHOLD = ( L.fab && L.fab.dragThreshold ) || 4; // px — ignore tiny accidental drags |
| 5719 |
const MIN_WIDTH = LPOP.minWidth || 360; |
| 5720 |
const MIN_HEIGHT = LPOP.minHeight || 400; |
| 5721 |
const MAX_WIDTH_MARGIN = LPOP.maxWidthMargin || 40; // min margin from viewport edge |
| 5722 |
const MAX_HEIGHT_MARGIN = LPOP.maxHeightMargin || 60; |
| 5723 |
|
| 5724 |
/** |
| 5725 |
* Sets up drag listeners on the container. |
| 5726 |
* Called once from WPBridgeHost.setup(). |
| 5727 |
* @param getBridgeLayout |
| 5728 |
*/ |
| 5729 |
function setupPopoverDrag( getBridgeLayout ) { |
| 5730 |
const container = document.getElementById( CONTAINER_ID ); |
| 5731 |
if ( ! container ) { |
| 5732 |
return; |
| 5733 |
} |
| 5734 |
|
| 5735 |
injectResizeHandles( container ); |
| 5736 |
|
| 5737 |
let isDragging = false; |
| 5738 |
let didMove = false; |
| 5739 |
let startX, startY, startLeft, startTop; |
| 5740 |
let isResizing = false; |
| 5741 |
let resizeEdges = null; |
| 5742 |
let startWidth, startHeight; |
| 5743 |
|
| 5744 |
function onMouseMove( e ) { |
| 5745 |
const dx = e.clientX - startX; |
| 5746 |
const dy = e.clientY - startY; |
| 5747 |
|
| 5748 |
// Ignore until threshold is met |
| 5749 |
if ( ! didMove && Math.abs( dx ) + Math.abs( dy ) < DRAG_THRESHOLD ) { |
| 5750 |
return; |
| 5751 |
} |
| 5752 |
didMove = true; |
| 5753 |
|
| 5754 |
let newLeft = startLeft + dx; |
| 5755 |
let newTop = startTop + dy; |
| 5756 |
|
| 5757 |
// Clamp to viewport |
| 5758 |
const cw = container.offsetWidth; |
| 5759 |
const ch = container.offsetHeight; |
| 5760 |
newLeft = Math.max( 0, Math.min( newLeft, window.innerWidth - cw ) ); |
| 5761 |
newTop = Math.max( 0, Math.min( newTop, window.innerHeight - ch ) ); |
| 5762 |
|
| 5763 |
container.style.left = newLeft + 'px'; |
| 5764 |
container.style.top = newTop + 'px'; |
| 5765 |
container.style.right = 'auto'; |
| 5766 |
container.style.bottom = 'auto'; |
| 5767 |
} |
| 5768 |
|
| 5769 |
function onMouseUp() { |
| 5770 |
if ( ! isDragging ) { |
| 5771 |
return; |
| 5772 |
} |
| 5773 |
isDragging = false; |
| 5774 |
document.body.style.userSelect = ''; |
| 5775 |
container.style.transition = ''; |
| 5776 |
document.removeEventListener( 'mousemove', onMouseMove ); |
| 5777 |
document.removeEventListener( 'mouseup', onMouseUp ); |
| 5778 |
// Persist position only if the user actually moved |
| 5779 |
if ( didMove ) { |
| 5780 |
savePosition(); |
| 5781 |
} |
| 5782 |
} |
| 5783 |
|
| 5784 |
container.addEventListener( 'mousedown', function( e ) { |
| 5785 |
if ( getBridgeLayout() !== 'popover' ) { |
| 5786 |
return; |
| 5787 |
} |
| 5788 |
|
| 5789 |
// Resize takes priority over drag |
| 5790 |
const resizeHandle = e.target.closest( RESIZE_HANDLE_SELECTOR ); |
| 5791 |
if ( resizeHandle ) { |
| 5792 |
isResizing = true; |
| 5793 |
resizeEdges = resizeHandle.getAttribute( 'data-popover-resize' ); |
| 5794 |
const rect = container.getBoundingClientRect(); |
| 5795 |
startX = e.clientX; |
| 5796 |
startY = e.clientY; |
| 5797 |
startLeft = rect.left; |
| 5798 |
startTop = rect.top; |
| 5799 |
startWidth = rect.width; |
| 5800 |
startHeight = rect.height; |
| 5801 |
|
| 5802 |
document.body.style.userSelect = 'none'; |
| 5803 |
container.style.transition = 'none'; |
| 5804 |
e.preventDefault(); |
| 5805 |
e.stopPropagation(); |
| 5806 |
|
| 5807 |
document.addEventListener( 'mousemove', onResizeMove ); |
| 5808 |
document.addEventListener( 'mouseup', onResizeEnd ); |
| 5809 |
return; |
| 5810 |
} |
| 5811 |
|
| 5812 |
// Only drag from header area |
| 5813 |
const dragHandle = e.target.closest( DRAG_HANDLE_SELECTOR ); |
| 5814 |
if ( ! dragHandle ) { |
| 5815 |
return; |
| 5816 |
} |
| 5817 |
// Don't drag when clicking interactive elements |
| 5818 |
if ( e.target.closest( INTERACTIVE_SELECTOR ) ) { |
| 5819 |
return; |
| 5820 |
} |
| 5821 |
|
| 5822 |
isDragging = true; |
| 5823 |
didMove = false; |
| 5824 |
const dragRect = container.getBoundingClientRect(); |
| 5825 |
startX = e.clientX; |
| 5826 |
startY = e.clientY; |
| 5827 |
startLeft = dragRect.left; |
| 5828 |
startTop = dragRect.top; |
| 5829 |
|
| 5830 |
document.body.style.userSelect = 'none'; |
| 5831 |
container.style.transition = 'none'; |
| 5832 |
e.preventDefault(); |
| 5833 |
|
| 5834 |
document.addEventListener( 'mousemove', onMouseMove ); |
| 5835 |
document.addEventListener( 'mouseup', onMouseUp ); |
| 5836 |
} ); |
| 5837 |
|
| 5838 |
function onResizeMove( e ) { |
| 5839 |
const dx = e.clientX - startX; |
| 5840 |
const dy = e.clientY - startY; |
| 5841 |
const maxW = window.innerWidth - MAX_WIDTH_MARGIN; |
| 5842 |
const maxH = window.innerHeight - MAX_HEIGHT_MARGIN; |
| 5843 |
let newWidth = startWidth; |
| 5844 |
let newHeight = startHeight; |
| 5845 |
let newLeft = startLeft; |
| 5846 |
let newTop = startTop; |
| 5847 |
|
| 5848 |
if ( resizeEdges.indexOf( 'e' ) !== -1 ) { |
| 5849 |
newWidth = clamp( startWidth + dx, MIN_WIDTH, maxW ); |
| 5850 |
} |
| 5851 |
if ( resizeEdges.indexOf( 'w' ) !== -1 ) { |
| 5852 |
newWidth = clamp( startWidth - dx, MIN_WIDTH, maxW ); |
| 5853 |
newLeft = startLeft + ( startWidth - newWidth ); |
| 5854 |
} |
| 5855 |
if ( resizeEdges.indexOf( 's' ) !== -1 ) { |
| 5856 |
newHeight = clamp( startHeight + dy, MIN_HEIGHT, maxH ); |
| 5857 |
} |
| 5858 |
if ( resizeEdges.indexOf( 'n' ) !== -1 ) { |
| 5859 |
newHeight = clamp( startHeight - dy, MIN_HEIGHT, maxH ); |
| 5860 |
newTop = startTop + ( startHeight - newHeight ); |
| 5861 |
} |
| 5862 |
|
| 5863 |
container.style.setProperty( '--zipwp-popover-w', newWidth + 'px' ); |
| 5864 |
container.style.setProperty( '--zipwp-popover-h', newHeight + 'px' ); |
| 5865 |
container.style.setProperty( '--zipwp-popover-max-h', 'none' ); |
| 5866 |
if ( resizeEdges.indexOf( 'w' ) !== -1 || resizeEdges.indexOf( 'n' ) !== -1 || container.style.left ) { |
| 5867 |
container.style.left = newLeft + 'px'; |
| 5868 |
container.style.top = newTop + 'px'; |
| 5869 |
container.style.right = 'auto'; |
| 5870 |
container.style.bottom = 'auto'; |
| 5871 |
} |
| 5872 |
} |
| 5873 |
|
| 5874 |
function onResizeEnd() { |
| 5875 |
if ( ! isResizing ) { |
| 5876 |
return; |
| 5877 |
} |
| 5878 |
isResizing = false; |
| 5879 |
resizeEdges = null; |
| 5880 |
document.body.style.userSelect = ''; |
| 5881 |
container.style.transition = ''; |
| 5882 |
document.removeEventListener( 'mousemove', onResizeMove ); |
| 5883 |
document.removeEventListener( 'mouseup', onResizeEnd ); |
| 5884 |
saveSize(); |
| 5885 |
if ( container.style.left ) { |
| 5886 |
savePosition(); |
| 5887 |
} |
| 5888 |
} |
| 5889 |
} |
| 5890 |
|
| 5891 |
function clamp( v, lo, hi ) { |
| 5892 |
return Math.max( lo, Math.min( hi, v ) ); |
| 5893 |
} |
| 5894 |
|
| 5895 |
/** |
| 5896 |
* Inject resize handle DOM elements — 4 edges + 4 corners. |
| 5897 |
* Only visible in popover mode (CSS-gated by body class). |
| 5898 |
* @param container |
| 5899 |
*/ |
| 5900 |
function injectResizeHandles( container ) { |
| 5901 |
if ( container.querySelector( RESIZE_HANDLE_SELECTOR ) ) { |
| 5902 |
return; |
| 5903 |
} |
| 5904 |
const edges = [ 'n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se' ]; |
| 5905 |
edges.forEach( function( edge ) { |
| 5906 |
const el = document.createElement( 'div' ); |
| 5907 |
el.setAttribute( 'data-popover-resize', edge ); |
| 5908 |
el.className = 'zipwp-popover-resize zipwp-popover-resize--' + edge; |
| 5909 |
container.appendChild( el ); |
| 5910 |
} ); |
| 5911 |
} |
| 5912 |
|
| 5913 |
function saveSize() { |
| 5914 |
const container = document.getElementById( CONTAINER_ID ); |
| 5915 |
if ( ! container ) { |
| 5916 |
return; |
| 5917 |
} |
| 5918 |
try { |
| 5919 |
localStorage.setItem( SIZE_STORAGE_KEY, JSON.stringify( { |
| 5920 |
width: container.offsetWidth, |
| 5921 |
height: container.offsetHeight, |
| 5922 |
} ) ); |
| 5923 |
} catch ( e ) {} |
| 5924 |
} |
| 5925 |
|
| 5926 |
function restoreSize() { |
| 5927 |
const container = document.getElementById( CONTAINER_ID ); |
| 5928 |
if ( ! container ) { |
| 5929 |
return; |
| 5930 |
} |
| 5931 |
try { |
| 5932 |
const raw = localStorage.getItem( SIZE_STORAGE_KEY ); |
| 5933 |
if ( ! raw ) { |
| 5934 |
return; |
| 5935 |
} |
| 5936 |
const size = JSON.parse( raw ); |
| 5937 |
if ( ! size || typeof size.width !== 'number' || typeof size.height !== 'number' ) { |
| 5938 |
return; |
| 5939 |
} |
| 5940 |
const maxW = window.innerWidth - MAX_WIDTH_MARGIN; |
| 5941 |
const maxH = window.innerHeight - MAX_HEIGHT_MARGIN; |
| 5942 |
const w = clamp( size.width, MIN_WIDTH, maxW ); |
| 5943 |
const h = clamp( size.height, MIN_HEIGHT, maxH ); |
| 5944 |
container.style.setProperty( '--zipwp-popover-w', w + 'px' ); |
| 5945 |
container.style.setProperty( '--zipwp-popover-h', h + 'px' ); |
| 5946 |
container.style.setProperty( '--zipwp-popover-max-h', 'none' ); |
| 5947 |
} catch ( e ) {} |
| 5948 |
} |
| 5949 |
|
| 5950 |
/** |
| 5951 |
* Save current container position to localStorage. |
| 5952 |
*/ |
| 5953 |
function savePosition() { |
| 5954 |
const container = document.getElementById( CONTAINER_ID ); |
| 5955 |
if ( ! container || ! container.style.left ) { |
| 5956 |
return; |
| 5957 |
} |
| 5958 |
try { |
| 5959 |
localStorage.setItem( STORAGE_KEY, JSON.stringify( { |
| 5960 |
left: parseInt( container.style.left, 10 ), |
| 5961 |
top: parseInt( container.style.top, 10 ), |
| 5962 |
// Viewport + panel size at save time. Restore uses these to |
| 5963 |
// re-anchor to the closer edge (so a right/bottom-docked panel |
| 5964 |
// keeps its margin when the window is a different size next |
| 5965 |
// load) AND to size the edge/clamp math off the panel's real |
| 5966 |
// dimensions — at load time offsetWidth is briefly the wide |
| 5967 |
// sidebar width before the popover width var applies. |
| 5968 |
vw: window.innerWidth, |
| 5969 |
vh: window.innerHeight, |
| 5970 |
w: container.offsetWidth, |
| 5971 |
h: container.offsetHeight, |
| 5972 |
} ) ); |
| 5973 |
} catch ( e ) {} |
| 5974 |
} |
| 5975 |
|
| 5976 |
/** |
| 5977 |
* Restore saved position from localStorage, clamped to current viewport. |
| 5978 |
*/ |
| 5979 |
function restorePosition() { |
| 5980 |
const container = document.getElementById( CONTAINER_ID ); |
| 5981 |
if ( ! container ) { |
| 5982 |
return; |
| 5983 |
} |
| 5984 |
// Restore size first so clamping uses the restored dimensions |
| 5985 |
restoreSize(); |
| 5986 |
try { |
| 5987 |
const raw = localStorage.getItem( STORAGE_KEY ); |
| 5988 |
if ( ! raw ) { |
| 5989 |
return; |
| 5990 |
} |
| 5991 |
const pos = JSON.parse( raw ); |
| 5992 |
if ( ! pos || typeof pos.left !== 'number' || typeof pos.top !== 'number' ) { |
| 5993 |
return; |
| 5994 |
} |
| 5995 |
|
| 5996 |
let left = pos.left; |
| 5997 |
let top = pos.top; |
| 5998 |
// Prefer the size saved with the position (avoids the load-time |
| 5999 |
// wide-sidebar offsetWidth that would skew the edge decision/clamp). |
| 6000 |
// Legacy records have no w/h — fall back to the post-restoreSize() |
| 6001 |
// offsetWidth so a resized-then-dragged panel still clamps to its |
| 6002 |
// real width, then to the default popover size. |
| 6003 |
const cw = pos.w || container.offsetWidth || ( LPOP.defaultWidth || 420 ); |
| 6004 |
const ch = pos.h || container.offsetHeight || ( LPOP.defaultHeight || 620 ); |
| 6005 |
|
| 6006 |
// Re-anchor to the closer edge if the viewport changed size since |
| 6007 |
// save: a right-docked panel shifts with the right edge (keeps its |
| 6008 |
// right margin); a left-docked one stays put. Same for top/bottom. |
| 6009 |
if ( typeof pos.vw === 'number' && pos.vw > 0 ) { |
| 6010 |
if ( pos.left + cw / 2 > pos.vw / 2 ) { |
| 6011 |
left += window.innerWidth - pos.vw; |
| 6012 |
} |
| 6013 |
} |
| 6014 |
if ( typeof pos.vh === 'number' && pos.vh > 0 ) { |
| 6015 |
if ( pos.top + ch / 2 > pos.vh / 2 ) { |
| 6016 |
top += window.innerHeight - pos.vh; |
| 6017 |
} |
| 6018 |
} |
| 6019 |
|
| 6020 |
// Clamp to viewport |
| 6021 |
if ( left + cw > window.innerWidth ) { |
| 6022 |
left = window.innerWidth - cw; |
| 6023 |
} |
| 6024 |
if ( top + ch > window.innerHeight ) { |
| 6025 |
top = window.innerHeight - ch; |
| 6026 |
} |
| 6027 |
if ( left < 0 ) { |
| 6028 |
left = 0; |
| 6029 |
} |
| 6030 |
if ( top < 0 ) { |
| 6031 |
top = 0; |
| 6032 |
} |
| 6033 |
|
| 6034 |
container.style.left = left + 'px'; |
| 6035 |
container.style.top = top + 'px'; |
| 6036 |
container.style.right = 'auto'; |
| 6037 |
container.style.bottom = 'auto'; |
| 6038 |
} catch ( e ) {} |
| 6039 |
} |
| 6040 |
|
| 6041 |
/** |
| 6042 |
* Clear saved position and inline styles from the container. |
| 6043 |
*/ |
| 6044 |
function clearPosition() { |
| 6045 |
const container = document.getElementById( CONTAINER_ID ); |
| 6046 |
if ( ! container ) { |
| 6047 |
return; |
| 6048 |
} |
| 6049 |
container.style.left = ''; |
| 6050 |
container.style.top = ''; |
| 6051 |
container.style.right = ''; |
| 6052 |
container.style.bottom = ''; |
| 6053 |
try { |
| 6054 |
localStorage.removeItem( STORAGE_KEY ); |
| 6055 |
} catch ( e ) {} |
| 6056 |
// Note: size intentionally NOT cleared here — user's custom popover |
| 6057 |
// size persists across mode switches. Only resetLayout() wipes size. |
| 6058 |
} |
| 6059 |
|
| 6060 |
/** |
| 6061 |
* Reset popover to default size and position. |
| 6062 |
* Removes CSS vars + inline position styles + clears storage. |
| 6063 |
*/ |
| 6064 |
function resetLayout() { |
| 6065 |
const container = document.getElementById( CONTAINER_ID ); |
| 6066 |
if ( ! container ) { |
| 6067 |
return; |
| 6068 |
} |
| 6069 |
container.style.left = ''; |
| 6070 |
container.style.top = ''; |
| 6071 |
container.style.right = ''; |
| 6072 |
container.style.bottom = ''; |
| 6073 |
container.style.removeProperty( '--zipwp-popover-w' ); |
| 6074 |
container.style.removeProperty( '--zipwp-popover-h' ); |
| 6075 |
container.style.removeProperty( '--zipwp-popover-max-h' ); |
| 6076 |
try { |
| 6077 |
localStorage.removeItem( STORAGE_KEY ); |
| 6078 |
localStorage.removeItem( SIZE_STORAGE_KEY ); |
| 6079 |
} catch ( e ) {} |
| 6080 |
} |
| 6081 |
|
| 6082 |
// Expose for WPBridgeHost |
| 6083 |
window.zipwpPopoverDrag = { |
| 6084 |
setup: setupPopoverDrag, |
| 6085 |
restorePosition, |
| 6086 |
clearPosition, |
| 6087 |
resetLayout, |
| 6088 |
}; |
| 6089 |
}() ); |
| 6090 |
|
| 6091 |
|
| 6092 |
/** |
| 6093 |
* ZipWP MCP — pure js_rpc dispatch-dedup decision (B-1 / P5). |
| 6094 |
* |
| 6095 |
* The bridge's executeTools loop owns the seen-map I/O (sessionStorage-backed, |
| 6096 |
* FIFO-bounded). THIS module is the pure DECISION over a prior seen entry, so the |
| 6097 |
* idempotency logic — the silent-until-it-bites duplicate-content path — is |
| 6098 |
* unit-tested instead of living only inline in the browser IIFE. |
| 6099 |
* |
| 6100 |
* Decisions, given the prior _rpcSeen entry for a js_rpc call_id (or undefined): |
| 6101 |
* - a prior entry (IN-FLIGHT or COMPLETED) → repost_cached: NEVER re-dispatch |
| 6102 |
* (re-running the handler double-applies the mutation); re-POST the stored |
| 6103 |
* reply so a still-waiting brain BRPOP resolves. |
| 6104 |
* - no prior entry → run: the caller records IN_FLIGHT_MARKER BEFORE the handler |
| 6105 |
* mutates the tree, so a crash BETWEEN the mutation and the completion-record |
| 6106 |
* still leaves a marker. A replay then reposts an uncertain (ok:false) reply |
| 6107 |
* and the brain verifies with get_context before any retry — never a blind |
| 6108 |
* re-dispatch. |
| 6109 |
* |
| 6110 |
* Dual-mode: window.zipwpRpcDedup in the browser; CommonJS for jest. |
| 6111 |
* |
| 6112 |
* @package |
| 6113 |
*/ |
| 6114 |
( function () { |
| 6115 |
'use strict'; |
| 6116 |
|
| 6117 |
// The reply a replay receives while the first run is mid-apply, or after a |
| 6118 |
// crash before the completion-record. ok:false → the brain treats it as an |
| 6119 |
// unconfirmed apply (verify-before-retry), never a blind re-apply. |
| 6120 |
const IN_FLIGHT_MARKER = { ok: false, data: undefined, error: 'apply_pending_no_confirmation' }; |
| 6121 |
|
| 6122 |
// A COMPLETED session-scoped write (apply_change / set_scripts — both mutate |
| 6123 |
// block attributes in the OPEN editor session) cached on a PRIOR page load: the |
| 6124 |
// page reloaded since, and Gutenberg DISCARDS unsaved session edits on reload. |
| 6125 |
// The cached ok:true would then mask a LOST edit (the brain folds success while |
| 6126 |
// the page reverted). Repost this uncertain reply so the brain re-verifies with |
| 6127 |
// get_context instead — same "don't assume, verify" contract as IN_FLIGHT. |
| 6128 |
const RELOAD_REVERIFY_MARKER = { ok: false, data: undefined, error: 'apply_unconfirmed_after_reload' }; |
| 6129 |
|
| 6130 |
// `currentPageLoadId` is the token minted once per page load. A cached entry |
| 6131 |
// tagged `mutating` (a session-scoped write) whose `pageLoadId` differs means a |
| 6132 |
// reload happened between the apply and this replay → the edit is gone. |
| 6133 |
function decideJsRpcDispatch( seenEntry, currentPageLoadId ) { |
| 6134 |
if ( seenEntry ) { |
| 6135 |
if ( seenEntry.mutating === true && |
| 6136 |
currentPageLoadId !== undefined && currentPageLoadId !== null && |
| 6137 |
seenEntry.pageLoadId !== undefined && |
| 6138 |
seenEntry.pageLoadId !== currentPageLoadId ) { |
| 6139 |
return { action: 'repost_cached', reply: RELOAD_REVERIFY_MARKER }; |
| 6140 |
} |
| 6141 |
return { action: 'repost_cached', reply: seenEntry }; |
| 6142 |
} |
| 6143 |
return { action: 'run', inFlightMarker: IN_FLIGHT_MARKER }; |
| 6144 |
} |
| 6145 |
|
| 6146 |
if ( typeof window !== 'undefined' ) { |
| 6147 |
window.zipwpRpcDedup = window.zipwpRpcDedup || {}; |
| 6148 |
window.zipwpRpcDedup.decideJsRpcDispatch = decideJsRpcDispatch; |
| 6149 |
window.zipwpRpcDedup.IN_FLIGHT_MARKER = IN_FLIGHT_MARKER; |
| 6150 |
window.zipwpRpcDedup.RELOAD_REVERIFY_MARKER = RELOAD_REVERIFY_MARKER; |
| 6151 |
} |
| 6152 |
if ( typeof module !== 'undefined' && module.exports ) { |
| 6153 |
module.exports = { |
| 6154 |
decideJsRpcDispatch, |
| 6155 |
IN_FLIGHT_MARKER, |
| 6156 |
RELOAD_REVERIFY_MARKER, |
| 6157 |
}; |
| 6158 |
} |
| 6159 |
}() ); |
| 6160 |
|
| 6161 |
|
| 6162 |
/** |
| 6163 |
* WordPress Bridge Host |
| 6164 |
* |
| 6165 |
* Same-window host bridge for the mounted React assistant. |
| 6166 |
* Exposes window.zipwpMcpBridge for the React DirectBridge to call. |
| 6167 |
*/ |
| 6168 |
|
| 6169 |
( function() { |
| 6170 |
'use strict'; |
| 6171 |
|
| 6172 |
// Block attribute keys that hold human-readable text (used by buildPageOutline |
| 6173 |
// to separate copy from config/style attrs). |
| 6174 |
const TEXT_KEYS = [ 'content', 'text', 'title', 'label', 'heading', 'question' ]; |
| 6175 |
|
| 6176 |
// B1 capability handshake — this bundle's browser-RPC protocol version, |
| 6177 |
// stamped into every editor context snapshot (getEditorContext). |
| 6178 |
// |
| 6179 |
// 1 = executes `js_rpc` envelopes for the editor/* handlers and POSTs replies |
| 6180 |
// to /agent/rpc-reply with {call_id, session_id, ok, data?, error?}. |
| 6181 |
// 2 = apply-change parses a resolved `section_markup` on the REVAMP path |
| 6182 |
// (replaceInnerBlocks), not just on an insert — so a form-bearing section |
| 6183 |
// placed as a revamp materializes a real form instead of an unconfigured |
| 6184 |
// placeholder. |
| 6185 |
// |
| 6186 |
// The brain refuses to route a turn to its AgentBrowserLoop unless it sees |
| 6187 |
// rpc >= 1, so ROUTING is unchanged by a bump. Bump when the dispatch/reply |
| 6188 |
// contract changes incompatibly OR when the brain must be able to tell whether |
| 6189 |
// this bundle can do something (the version is the ONE capability signal — |
| 6190 |
// do not add a parallel flag). The brain reads this as |
| 6191 |
// REVAMP_MATERIALIZATION_RPC_VERSION and degrades CLOSED below it: it keeps |
| 6192 |
// warning the model that a revamped form would ship broken, because on an |
| 6193 |
// un-updated site it still would. |
| 6194 |
const RPC_PROTOCOL_VERSION = 2; |
| 6195 |
|
| 6196 |
// B-1 idempotency — a js_rpc apply mutates the live block tree BEFORE it |
| 6197 |
// POSTs its reply, so a SECOND dispatch of the SAME tool_use call_id (an SSE |
| 6198 |
// replay, a brain turn-replay after a mid-turn restart, OR hydrateSession's |
| 6199 |
// replay_events after a page reload) would apply the mutation twice and |
| 6200 |
// silently duplicate content. We record every js_rpc call_id we have already |
| 6201 |
// executed, with the reply we produced; an exact replay skips the handler |
| 6202 |
// entirely and just re-POSTs the cached reply (the brain may still be |
| 6203 |
// BRPOP-waiting on it). Bounded FIFO so it can't grow unbounded. |
| 6204 |
// |
| 6205 |
// M3 — backed by sessionStorage (per-tab, survives a reload) because the |
| 6206 |
// duplicate-apply hazard's worst case is exactly a mid-turn reload: the user |
| 6207 |
// saves, reloads, hydrateSession replays the turn's tool_call_result events |
| 6208 |
// through the SAME handlers, and a memory-only map is gone. Degrades to the |
| 6209 |
// in-memory map when sessionStorage is unavailable (quota / privacy mode) — |
| 6210 |
// never throws into the dispatch path. |
| 6211 |
const RPC_DEDUP_MAX = 64; |
| 6212 |
const RPC_DEDUP_STORAGE_KEY = 'zipwpRpcSeen.v1'; |
| 6213 |
const _rpcSeen = loadRpcSeen(); // call_id -> { ok, data, error, mutating?, pageLoadId? } |
| 6214 |
// Unique per PAGE LOAD (a reload mints a fresh module + a fresh id, while the |
| 6215 |
// sessionStorage-backed _rpcSeen survives). A cached session-scoped write whose |
| 6216 |
// pageLoadId differs from this = the page reloaded since the apply → the |
| 6217 |
// in-memory edit was discarded (see rpc-dedup.js RELOAD_REVERIFY_MARKER). |
| 6218 |
const PAGE_LOAD_ID = 'pl-' + Date.now() + '-' + Math.random().toString( 36 ).slice( 2, 8 ); |
| 6219 |
// Session-scoped writes (discarded on reload) — apply_change + set_scripts both |
| 6220 |
// mutate block attributes in the open editor session; set_styles writes REST |
| 6221 |
// (survives reload) so it is NOT here. |
| 6222 |
const SESSION_SCOPED_TOOLS = { 'editor/apply-change': true, 'editor/set-scripts': true }; |
| 6223 |
function loadRpcSeen() { |
| 6224 |
try { |
| 6225 |
const raw = window.sessionStorage && window.sessionStorage.getItem( RPC_DEDUP_STORAGE_KEY ); |
| 6226 |
if ( raw ) { |
| 6227 |
const entries = JSON.parse( raw ); |
| 6228 |
if ( Array.isArray( entries ) ) { |
| 6229 |
return new Map( entries ); |
| 6230 |
} |
| 6231 |
} |
| 6232 |
} catch ( e ) { /* corrupt/unavailable storage → start fresh */ } |
| 6233 |
return new Map(); |
| 6234 |
} |
| 6235 |
function saveRpcSeen() { |
| 6236 |
try { |
| 6237 |
if ( ! window.sessionStorage ) { |
| 6238 |
return; |
| 6239 |
} |
| 6240 |
window.sessionStorage.setItem( |
| 6241 |
RPC_DEDUP_STORAGE_KEY, |
| 6242 |
JSON.stringify( Array.from( _rpcSeen.entries() ) ) |
| 6243 |
); |
| 6244 |
} catch ( e ) { /* quota/unavailable → memory-only behaviour, same as before */ } |
| 6245 |
} |
| 6246 |
function rpcSeenGet( callId ) { |
| 6247 |
return callId ? _rpcSeen.get( callId ) : undefined; |
| 6248 |
} |
| 6249 |
function rpcSeenRemember( callId, ok, data, error, meta ) { |
| 6250 |
if ( ! callId ) { |
| 6251 |
return; |
| 6252 |
} |
| 6253 |
if ( _rpcSeen.has( callId ) ) { |
| 6254 |
_rpcSeen.delete( callId ); |
| 6255 |
} |
| 6256 |
const entry = { ok, data, error }; |
| 6257 |
// Tag session-scoped completed writes with the page-load id so a later |
| 6258 |
// replay after a reload reposts an uncertain reply instead of a vanished |
| 6259 |
// success (F2). Reads / non-session writes stay untagged (repost cached). |
| 6260 |
if ( meta && meta.mutating === true ) { |
| 6261 |
entry.mutating = true; |
| 6262 |
entry.pageLoadId = meta.pageLoadId; |
| 6263 |
} |
| 6264 |
_rpcSeen.set( callId, entry ); |
| 6265 |
while ( _rpcSeen.size > RPC_DEDUP_MAX ) { |
| 6266 |
_rpcSeen.delete( _rpcSeen.keys().next().value ); |
| 6267 |
} |
| 6268 |
saveRpcSeen(); |
| 6269 |
} |
| 6270 |
|
| 6271 |
// Map WP editor getSettings() colors/fontSizes (`[{name, slug, color|size}]`) |
| 6272 |
// into the brain's wire shape `{ colors: [{slug, hex}], font_sizes: [{slug, size}] }`. |
| 6273 |
// Drops malformed entries (blank slug/value). `size` is coerced to a string |
| 6274 |
// (theme.json may give numbers). Returns null when no valid tokens exist, so |
| 6275 |
// the caller omits `theme_tokens` entirely rather than send an empty object. |
| 6276 |
function buildThemeTokens( settings ) { |
| 6277 |
const tokens = {}; |
| 6278 |
|
| 6279 |
// WP exposes the palette in TWO shapes and a BLOCK THEME (theme.json) uses |
| 6280 |
// ONLY the second: the legacy flat `settings.colors` (classic themes), and |
| 6281 |
// `settings.__experimentalFeatures.color.palette.{theme,custom,default}` |
| 6282 |
// (block themes). Reading only the flat array meant a theme.json site sent |
| 6283 |
// NO palette, so the brain styled new sections with the generic default |
| 6284 |
// accent (cyan) instead of the site's real brand. Prefer the flat array; |
| 6285 |
// fall back to the experimental palette (theme + custom — the site's own |
| 6286 |
// colours, not WP's built-in defaults). |
| 6287 |
const exp = settings.__experimentalFeatures || {}; |
| 6288 |
const expPalette = ( exp.color && exp.color.palette ) || {}; |
| 6289 |
const srcColors = ( Array.isArray( settings.colors ) && settings.colors.length ) |
| 6290 |
? settings.colors |
| 6291 |
: [].concat( |
| 6292 |
Array.isArray( expPalette.theme ) ? expPalette.theme : [], |
| 6293 |
Array.isArray( expPalette.custom ) ? expPalette.custom : [], |
| 6294 |
); |
| 6295 |
const colors = []; |
| 6296 |
// Null-proto so a palette slug that collides with an Object.prototype key |
| 6297 |
// (constructor, __proto__, hasOwnProperty) can't resolve to an inherited |
| 6298 |
// truthy value and silently drop that colour from theme_tokens. |
| 6299 |
const seenColor = Object.create( null ); |
| 6300 |
for ( let i = 0; i < srcColors.length; i++ ) { |
| 6301 |
const c = srcColors[ i ] || {}; |
| 6302 |
const cslug = typeof c.slug === 'string' ? c.slug : ''; |
| 6303 |
const hex = typeof c.color === 'string' ? c.color : ''; |
| 6304 |
if ( cslug && hex && ! seenColor[ cslug ] ) { |
| 6305 |
seenColor[ cslug ] = true; |
| 6306 |
colors.push( { slug: cslug, hex } ); |
| 6307 |
} |
| 6308 |
} |
| 6309 |
if ( colors.length ) { |
| 6310 |
tokens.colors = colors; |
| 6311 |
} |
| 6312 |
|
| 6313 |
const expTypo = exp.typography || {}; |
| 6314 |
const srcSizes = ( Array.isArray( settings.fontSizes ) && settings.fontSizes.length ) |
| 6315 |
? settings.fontSizes |
| 6316 |
: ( Array.isArray( expTypo.fontSizes ) ? expTypo.fontSizes : [] ); |
| 6317 |
const fontSizes = []; |
| 6318 |
// Null-proto: same prototype-key guard as seenColor above. |
| 6319 |
const seenSize = Object.create( null ); |
| 6320 |
for ( let j = 0; j < srcSizes.length; j++ ) { |
| 6321 |
const f = srcSizes[ j ] || {}; |
| 6322 |
const fslug = typeof f.slug === 'string' ? f.slug : ''; |
| 6323 |
const size = f.size; |
| 6324 |
const hasSize = typeof size === 'string' ? size !== '' : typeof size === 'number'; |
| 6325 |
if ( fslug && hasSize && ! seenSize[ fslug ] ) { |
| 6326 |
seenSize[ fslug ] = true; |
| 6327 |
fontSizes.push( { slug: fslug, size: String( size ) } ); |
| 6328 |
} |
| 6329 |
} |
| 6330 |
if ( fontSizes.length ) { |
| 6331 |
tokens.font_sizes = fontSizes; |
| 6332 |
} |
| 6333 |
|
| 6334 |
return ( tokens.colors || tokens.font_sizes ) ? tokens : null; |
| 6335 |
} |
| 6336 |
|
| 6337 |
// The dominant repeated DIRECT-child block type — the block name occurring |
| 6338 |
// 3+ times among `children`, with the highest count — or null when none |
| 6339 |
// qualifies (fewer than 3 children, or no single type reaches 3). One |
| 6340 |
// detection shared by the page outline (repeated_children flag) and the |
| 6341 |
// selected-block repeater signal (flag + count + clone target), so the two |
| 6342 |
// can never drift. Iframe path mirrors this in src/utils/selectedBlockDto.js. |
| 6343 |
function dominantRepeatedChildType( children ) { |
| 6344 |
if ( ! Array.isArray( children ) || children.length < 3 ) { |
| 6345 |
return null; |
| 6346 |
} |
| 6347 |
const counts = {}; |
| 6348 |
for ( let i = 0; i < children.length; i++ ) { |
| 6349 |
const name = children[ i ] && children[ i ].name; |
| 6350 |
if ( typeof name === 'string' ) { |
| 6351 |
counts[ name ] = ( counts[ name ] || 0 ) + 1; |
| 6352 |
} |
| 6353 |
} |
| 6354 |
let best = null; |
| 6355 |
const names = Object.keys( counts ); |
| 6356 |
for ( let k = 0; k < names.length; k++ ) { |
| 6357 |
const n = counts[ names[ k ] ]; |
| 6358 |
if ( n >= 3 && ( best === null || n > best.count ) ) { |
| 6359 |
best = { name: names[ k ], count: n }; |
| 6360 |
} |
| 6361 |
} |
| 6362 |
return best; |
| 6363 |
} |
| 6364 |
|
| 6365 |
// A grid nested ONE level down: when the DIRECT children aren't a repeater |
| 6366 |
// but EXACTLY ONE direct child is a container whose children ARE a repeater, |
| 6367 |
// that inner container is the grid (the Spectra `section > content > |
| 6368 |
// [card×N]` layout). "Exactly one" keeps it unambiguous. Iframe path mirrors |
| 6369 |
// this in src/utils/selectedBlockDto.js::nestedGrid — keep both in lockstep. |
| 6370 |
function nestedGrid( children ) { |
| 6371 |
let found = null; |
| 6372 |
for ( let i = 0; i < children.length; i++ ) { |
| 6373 |
const c = children[ i ]; |
| 6374 |
const inner = c && Array.isArray( c.innerBlocks ) ? c.innerBlocks : null; |
| 6375 |
if ( ! inner ) { |
| 6376 |
continue; |
| 6377 |
} |
| 6378 |
const d = dominantRepeatedChildType( inner ); |
| 6379 |
if ( d === null ) { |
| 6380 |
continue; |
| 6381 |
} |
| 6382 |
if ( found !== null ) { |
| 6383 |
return null; |
| 6384 |
} // 2+ candidate grids — ambiguous, bail |
| 6385 |
found = { dominant: d, children: inner }; |
| 6386 |
} |
| 6387 |
return found; |
| 6388 |
} |
| 6389 |
|
| 6390 |
class WPBridgeHost { |
| 6391 |
constructor() { |
| 6392 |
this.config = window.zipwpIframeConfig || {}; |
| 6393 |
this._snapshotCounter = 0; |
| 6394 |
this._selectionRevision = 0; |
| 6395 |
this._lastSelectedId = null; |
| 6396 |
this.init(); |
| 6397 |
} |
| 6398 |
|
| 6399 |
init() { |
| 6400 |
if ( document.readyState === 'loading' ) { |
| 6401 |
document.addEventListener( 'DOMContentLoaded', () => this.setup() ); |
| 6402 |
} else { |
| 6403 |
this.setup(); |
| 6404 |
} |
| 6405 |
} |
| 6406 |
|
| 6407 |
setup() { |
| 6408 |
this.setupAdminBarToggle(); |
| 6409 |
this.setupResizeHandle(); |
| 6410 |
this.loadSavedSidebarWidth(); |
| 6411 |
this.setupInlineEditShortcut(); |
| 6412 |
this.restorePanelLayout(); |
| 6413 |
if ( window.zipwpPopoverDrag ) { |
| 6414 |
const bridge = this; |
| 6415 |
window.zipwpPopoverDrag.setup( function() { |
| 6416 |
return bridge._panelLayout || 'sidebar'; |
| 6417 |
} ); |
| 6418 |
} |
| 6419 |
this.checkAutoOpen(); |
| 6420 |
this.restorePanelState(); |
| 6421 |
} |
| 6422 |
|
| 6423 |
// ── Panel Toggle ────────────────────────────────────────────── |
| 6424 |
|
| 6425 |
setupAdminBarToggle() { |
| 6426 |
const self = this; |
| 6427 |
document.addEventListener( 'click', function( e ) { |
| 6428 |
const trigger = e.target.closest( '#zip-ai-floating-trigger' ); |
| 6429 |
if ( trigger ) { |
| 6430 |
e.preventDefault(); |
| 6431 |
self.togglePanel(); |
| 6432 |
} |
| 6433 |
} ); |
| 6434 |
|
| 6435 |
// Escape closes the panel when focus is inside it. |
| 6436 |
document.addEventListener( 'keydown', function( e ) { |
| 6437 |
if ( e.key !== 'Escape' ) { |
| 6438 |
return; |
| 6439 |
} |
| 6440 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 6441 |
if ( ! container || ! container.classList.contains( 'zip-ai-iframe-visible' ) ) { |
| 6442 |
return; |
| 6443 |
} |
| 6444 |
// Escape belongs to the TOPMOST layer. The React app mounts |
| 6445 |
// directly into this container (same document, despite the |
| 6446 |
// `iframe` class name), and this listener is on `document` |
| 6447 |
// while the app's own Esc handlers are on `window` — document |
| 6448 |
// fires FIRST on the bubble path, so without this guard the |
| 6449 |
// panel closed out from under an open overlay: Escape in the |
| 6450 |
// fullscreen pick-a-look preview dismissed the preview AND the |
| 6451 |
// whole chat, instead of dropping back to the conversation. |
| 6452 |
// |
| 6453 |
// Keyed on the ARIA contract, not on a component or class name, |
| 6454 |
// so every dialog in the app — today's preview stage, session |
| 6455 |
// list, attachment preview, and anything added later — owns its |
| 6456 |
// own Escape for free. |
| 6457 |
if ( container.querySelector( '[role="dialog"][aria-modal="true"]' ) ) { |
| 6458 |
return; |
| 6459 |
} |
| 6460 |
// Only close if focus is inside the panel (or on body — e.g. after clicking inside) |
| 6461 |
// eslint-disable-next-line @wordpress/no-global-active-element |
| 6462 |
if ( container.contains( document.activeElement ) || document.activeElement === document.body ) { |
| 6463 |
e.preventDefault(); |
| 6464 |
self.togglePanel(); |
| 6465 |
} |
| 6466 |
} ); |
| 6467 |
|
| 6468 |
// Global keyboard shortcut: |
| 6469 |
// - Primary: Cmd+E / Ctrl+E (more reliable across OS/browser combos) |
| 6470 |
// - Legacy: Cmd/Ctrl+Shift+Space (kept for backward compatibility) |
| 6471 |
// Registered on the parent page so it works even when the panel is closed. |
| 6472 |
document.addEventListener( 'keydown', function( e ) { |
| 6473 |
const modKey = e.metaKey || e.ctrlKey; |
| 6474 |
|
| 6475 |
// Avoid intercepting typing inside form fields or contenteditable regions. |
| 6476 |
const target = e.target; |
| 6477 |
const tagName = target && target.tagName; |
| 6478 |
const isTextInput = |
| 6479 |
tagName === 'INPUT' || |
| 6480 |
tagName === 'TEXTAREA' || |
| 6481 |
( target && target.isContentEditable ); |
| 6482 |
|
| 6483 |
if ( isTextInput || ! modKey ) { |
| 6484 |
return; |
| 6485 |
} |
| 6486 |
|
| 6487 |
// Primary shortcut: Cmd/Ctrl + E |
| 6488 |
if ( String( e.key ).toLowerCase() === 'e' ) { |
| 6489 |
e.preventDefault(); |
| 6490 |
self.togglePanel(); |
| 6491 |
return; |
| 6492 |
} |
| 6493 |
|
| 6494 |
// Legacy shortcut: Cmd/Ctrl + Shift + Space |
| 6495 |
if ( ! e.shiftKey ) { |
| 6496 |
return; |
| 6497 |
} |
| 6498 |
|
| 6499 |
const isSpaceKey = |
| 6500 |
e.code === 'Space' || |
| 6501 |
e.key === ' ' || |
| 6502 |
e.key === 'Spacebar'; |
| 6503 |
|
| 6504 |
if ( ! isSpaceKey ) { |
| 6505 |
return; |
| 6506 |
} |
| 6507 |
|
| 6508 |
e.preventDefault(); |
| 6509 |
self.togglePanel(); |
| 6510 |
} ); |
| 6511 |
} |
| 6512 |
|
| 6513 |
togglePanel() { |
| 6514 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 6515 |
if ( ! container ) { |
| 6516 |
// Full-page mode has no sidebar container. "Close" navigates back to admin home. |
| 6517 |
if ( this.config.displayMode === 'fullpage' ) { |
| 6518 |
window.location.href = this.config.adminHomeUrl || '/wp-admin/'; |
| 6519 |
} |
| 6520 |
return; |
| 6521 |
} |
| 6522 |
|
| 6523 |
const isVisible = container.classList.contains( 'zip-ai-iframe-visible' ); |
| 6524 |
|
| 6525 |
if ( isVisible ) { |
| 6526 |
container.classList.remove( 'zip-ai-iframe-visible' ); |
| 6527 |
document.body.classList.remove( 'zip-ai-assistant-open' ); |
| 6528 |
this.toggleFullscreen( false ); |
| 6529 |
// Make hidden container fully non-interactive (prevents click interception). |
| 6530 |
container.setAttribute( 'inert', '' ); |
| 6531 |
|
| 6532 |
// Disable block context picker when sidebar closes |
| 6533 |
if ( window.zipwpMcpContextPicker ) { |
| 6534 |
window.zipwpMcpContextPicker.disable(); |
| 6535 |
} |
| 6536 |
|
| 6537 |
try { |
| 6538 |
localStorage.setItem( 'zipwp-panel-open', '0' ); |
| 6539 |
} catch ( e ) {} |
| 6540 |
} else { |
| 6541 |
container.removeAttribute( 'inert' ); |
| 6542 |
container.classList.add( 'zip-ai-iframe-visible' ); |
| 6543 |
document.body.classList.add( 'zip-ai-assistant-open' ); |
| 6544 |
this.emitFullscreenChanged( container.classList.contains( 'zip-ai-iframe-fullscreen' ) ); |
| 6545 |
|
| 6546 |
// Enable block context picker when sidebar opens |
| 6547 |
if ( window.zipwpMcpContextPicker ) { |
| 6548 |
window.zipwpMcpContextPicker.enable(); |
| 6549 |
} |
| 6550 |
|
| 6551 |
try { |
| 6552 |
localStorage.setItem( 'zipwp-panel-open', '1' ); |
| 6553 |
} catch ( e ) {} |
| 6554 |
} |
| 6555 |
} |
| 6556 |
|
| 6557 |
checkAutoOpen() { |
| 6558 |
const urlParams = new URLSearchParams( window.location.search ); |
| 6559 |
|
| 6560 |
// Legacy param: ?zipwp_open_assistant |
| 6561 |
if ( urlParams.has( 'zipwp_open_assistant' ) ) { |
| 6562 |
this._autoOpened = true; |
| 6563 |
urlParams.delete( 'zipwp_open_assistant' ); |
| 6564 |
const newUrl = window.location.pathname + |
| 6565 |
( urlParams.toString() ? '?' + urlParams.toString() : '' ) + |
| 6566 |
window.location.hash; |
| 6567 |
window.history.replaceState( {}, '', newUrl ); |
| 6568 |
|
| 6569 |
// eslint-disable-next-line no-var |
| 6570 |
var self = this; |
| 6571 |
setTimeout( function() { |
| 6572 |
self.togglePanel(); |
| 6573 |
}, 300 ); |
| 6574 |
return; |
| 6575 |
} |
| 6576 |
|
| 6577 |
// New param: ?auto_open=true&prompt=... |
| 6578 |
if ( urlParams.get( 'auto_open' ) === 'true' ) { |
| 6579 |
const prompt = urlParams.get( 'prompt' ) || ''; |
| 6580 |
|
| 6581 |
// Clean URL params |
| 6582 |
urlParams.delete( 'auto_open' ); |
| 6583 |
urlParams.delete( 'prompt' ); |
| 6584 |
urlParams.delete( 'mode' ); |
| 6585 |
const cleanUrl = window.location.pathname + |
| 6586 |
( urlParams.toString() ? '?' + urlParams.toString() : '' ) + |
| 6587 |
window.location.hash; |
| 6588 |
window.history.replaceState( {}, '', cleanUrl ); |
| 6589 |
|
| 6590 |
// Stash prompt for React to pick up after mount. sessionStorage |
| 6591 |
// (not a window global) so it survives the logged-out |
| 6592 |
// login -> reloadWithAutoOpen() full-page reload before an |
| 6593 |
// authed InputBox can consume it. |
| 6594 |
if ( prompt ) { |
| 6595 |
try { |
| 6596 |
sessionStorage.setItem( 'zipaiAutoPrompt', prompt ); |
| 6597 |
} catch ( e ) {} |
| 6598 |
} |
| 6599 |
|
| 6600 |
this._autoOpened = true; |
| 6601 |
|
| 6602 |
// Open sidebar panel if not already on fullpage (fullpage is always open) |
| 6603 |
if ( this.config.displayMode !== 'fullpage' ) { |
| 6604 |
// eslint-disable-next-line no-redeclare, no-var |
| 6605 |
var self = this; |
| 6606 |
setTimeout( function() { |
| 6607 |
self.togglePanel(); |
| 6608 |
}, 300 ); |
| 6609 |
} |
| 6610 |
} |
| 6611 |
} |
| 6612 |
|
| 6613 |
restorePanelState() { |
| 6614 |
// Skip in full-page mode — panel is always visible there. |
| 6615 |
if ( this.config.displayMode === 'fullpage' ) { |
| 6616 |
return; |
| 6617 |
} |
| 6618 |
// Skip if checkAutoOpen already scheduled a deferred toggle — avoid double-toggle race. |
| 6619 |
if ( this._autoOpened ) { |
| 6620 |
return; |
| 6621 |
} |
| 6622 |
|
| 6623 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 6624 |
if ( ! container ) { |
| 6625 |
return; |
| 6626 |
} |
| 6627 |
|
| 6628 |
const alreadyOpen = container.classList.contains( 'zip-ai-iframe-visible' ); |
| 6629 |
let wasOpen = false; |
| 6630 |
try { |
| 6631 |
wasOpen = localStorage.getItem( 'zipwp-panel-open' ) === '1'; |
| 6632 |
} catch ( e ) {} |
| 6633 |
|
| 6634 |
if ( wasOpen && ! alreadyOpen ) { |
| 6635 |
this.togglePanel(); |
| 6636 |
} |
| 6637 |
} |
| 6638 |
|
| 6639 |
// ── Panel Layout (sidebar vs popover) ───────────────────────── |
| 6640 |
// 'sidebar' = pushes WP content left (default) |
| 6641 |
// 'popover' = floating card, no viewport shrink |
| 6642 |
|
| 6643 |
restorePanelLayout() { |
| 6644 |
// Resolution (explicit choice wins; else context default) lives in the |
| 6645 |
// ZIPWP_LAYOUT SSOT. |
| 6646 |
const L = window.ZIPWP_LAYOUT; |
| 6647 |
this._panelLayout = L ? L.resolveLayout() : 'sidebar'; |
| 6648 |
this._applyPanelLayoutClasses( this._panelLayout ); |
| 6649 |
} |
| 6650 |
|
| 6651 |
setPanelLayout( layout ) { |
| 6652 |
const L = window.ZIPWP_LAYOUT; |
| 6653 |
// Validate via the SSOT when present; fall back to the known layouts so |
| 6654 |
// a missing config never lets an arbitrary string get persisted. |
| 6655 |
const valid = L ? L.isValidLayout( layout ) : ( layout === 'sidebar' || layout === 'popover' ); |
| 6656 |
if ( ! valid ) { |
| 6657 |
return; |
| 6658 |
} |
| 6659 |
this._panelLayout = layout; |
| 6660 |
try { |
| 6661 |
localStorage.setItem( L ? L.keys.layout : 'zipwp-panel-layout', layout ); |
| 6662 |
} catch ( e ) {} |
| 6663 |
// _applyPanelLayoutClasses already calls drag.clearPosition() |
| 6664 |
// on the non-popover branch — no additional cleanup needed. |
| 6665 |
this._applyPanelLayoutClasses( layout ); |
| 6666 |
} |
| 6667 |
|
| 6668 |
_applyPanelLayoutClasses( layout ) { |
| 6669 |
const drag = window.zipwpPopoverDrag; |
| 6670 |
document.body.classList.remove( 'zip-ai-overlay-mode', 'zip-ai-popover-mode' ); |
| 6671 |
if ( layout === 'popover' ) { |
| 6672 |
document.body.classList.add( 'zip-ai-popover-mode' ); |
| 6673 |
if ( drag ) { |
| 6674 |
drag.restorePosition(); |
| 6675 |
} |
| 6676 |
} else if ( drag ) { |
| 6677 |
drag.clearPosition(); |
| 6678 |
} |
| 6679 |
} |
| 6680 |
|
| 6681 |
// Reset EVERYTHING (layout choice, popover size/position, FAB position, |
| 6682 |
// theme) to defaults via the SSOT — which clears storage + broadcasts |
| 6683 |
// the reset event that the FAB launcher and header theme listen for — |
| 6684 |
// then re-resolve + re-apply the context-aware default here. |
| 6685 |
resetPopoverLayout() { |
| 6686 |
const drag = window.zipwpPopoverDrag; |
| 6687 |
if ( drag && drag.resetLayout ) { |
| 6688 |
drag.resetLayout(); |
| 6689 |
} |
| 6690 |
if ( window.ZIPWP_LAYOUT ) { |
| 6691 |
window.ZIPWP_LAYOUT.resetToDefaults(); |
| 6692 |
} |
| 6693 |
this.restorePanelLayout(); |
| 6694 |
} |
| 6695 |
|
| 6696 |
reloadWithAutoOpen() { |
| 6697 |
// In full-page mode, just reload — the assistant is already the whole page. |
| 6698 |
if ( this.config.displayMode === 'fullpage' ) { |
| 6699 |
window.location.reload(); |
| 6700 |
return; |
| 6701 |
} |
| 6702 |
const url = new URL( window.location.href ); |
| 6703 |
url.searchParams.set( 'zipwp_open_assistant', '1' ); |
| 6704 |
window.location.href = url.toString(); |
| 6705 |
} |
| 6706 |
|
| 6707 |
// ── Context ─────────────────────────────────────────────────── |
| 6708 |
|
| 6709 |
getContext() { |
| 6710 |
return { |
| 6711 |
wp_user_id: this.config.userId || null, |
| 6712 |
editor_context: this.getEditorContext(), |
| 6713 |
page_context: this.getPageContext(), |
| 6714 |
website_context: { |
| 6715 |
current_url: window.location.href, |
| 6716 |
...( this.config.websiteContext || {} ), |
| 6717 |
}, |
| 6718 |
theme_context: this.config.themeContext || {}, |
| 6719 |
installed_plugins: this.config.installedPlugins || {}, |
| 6720 |
admin_screen: this.getAdminScreenContext(), |
| 6721 |
}; |
| 6722 |
} |
| 6723 |
|
| 6724 |
getEditorContext() { |
| 6725 |
const snapshotId = ++this._snapshotCounter; |
| 6726 |
|
| 6727 |
// PHP-authoritative flag from `get_current_screen()->is_block_editor()`, |
| 6728 |
// already narrowed server-side to the native post/page editor |
| 6729 |
// (screen base 'post'; see React_Manager::is_block_editor_screen). |
| 6730 |
// Used as a fallback when `wp.data` / `core/block-editor` haven't |
| 6731 |
// initialized yet at iframe boot — so the brain doesn't see |
| 6732 |
// is_block_editor=false on the first turn and route to dashboard mode. |
| 6733 |
const phpIsBlockEditor = !! ( this.config && this.config.isBlockEditor ); |
| 6734 |
|
| 6735 |
// Native post/page editor URL gate. The DOM/store heuristic below |
| 6736 |
// (block-list layout, .editor-styles-wrapper, #editor) matches ANY |
| 6737 |
// screen rendering @wordpress/block-editor — including SureCart's |
| 6738 |
// page editor and the Site Editor — so without this gate it would |
| 6739 |
// re-flag those as the block editor even after PHP is narrowed. |
| 6740 |
// The host bridge runs in the parent admin window, so location is |
| 6741 |
// the real wp-admin URL: only post.php / post-new.php may qualify. |
| 6742 |
const editorPath = ( window.location && window.location.pathname ) || ''; |
| 6743 |
// eslint-disable-next-line @wordpress/no-unused-vars-before-return |
| 6744 |
const isNativePostEditorUrl = /\/(post|post-new)\.php$/.test( editorPath ); |
| 6745 |
|
| 6746 |
const editorContext = { |
| 6747 |
is_block_editor: phpIsBlockEditor, |
| 6748 |
// B1 capability handshake — this bundle's browser-RPC protocol |
| 6749 |
// version. Declares that THIS host bridge executes js_rpc |
| 6750 |
// editor/* dispatches and POSTs replies to /agent/rpc-reply. |
| 6751 |
// The brain routes to its AgentBrowserLoop ONLY when it sees |
| 6752 |
// rpc >= 1; a bundle without the stamp (v1) degrades to the |
| 6753 |
// dashboard flow instead of timing out on every editor tool. |
| 6754 |
// Bump ONLY when the dispatch/reply contract changes shape. |
| 6755 |
rpc: RPC_PROTOCOL_VERSION, |
| 6756 |
selected_block: null, |
| 6757 |
}; |
| 6758 |
|
| 6759 |
if ( ! window.wp || ! window.wp.data ) { |
| 6760 |
return editorContext; |
| 6761 |
} |
| 6762 |
|
| 6763 |
const select = window.wp.data.select; |
| 6764 |
const blockEditorSelect = select( 'core/block-editor' ); |
| 6765 |
if ( ! blockEditorSelect ) { |
| 6766 |
return editorContext; |
| 6767 |
} |
| 6768 |
|
| 6769 |
const currentSelectedId = blockEditorSelect.getSelectedBlockClientId(); |
| 6770 |
if ( currentSelectedId !== this._lastSelectedId ) { |
| 6771 |
this._lastSelectedId = currentSelectedId; |
| 6772 |
this._selectionRevision = ( this._selectionRevision || 0 ) + 1; |
| 6773 |
} |
| 6774 |
|
| 6775 |
const blocks = blockEditorSelect.getBlocks(); |
| 6776 |
const hasEditorDOM = document.body.classList.contains( 'block-editor-page' ) || |
| 6777 |
document.querySelector( '.block-editor-block-list__layout' ) || |
| 6778 |
document.querySelector( '.editor-styles-wrapper' ) || |
| 6779 |
document.querySelector( '#editor' ); |
| 6780 |
|
| 6781 |
// Fallback: core/editor.getEditingMode() returns 'visual' in block editor context |
| 6782 |
// Guards against DOM selector drift across WP versions |
| 6783 |
const coreEditorSelectEarly = select( 'core/editor' ); |
| 6784 |
const hasEditorMode = coreEditorSelectEarly |
| 6785 |
? ( coreEditorSelectEarly.getEditingMode?.() === 'visual' || coreEditorSelectEarly.getCurrentPostId?.() > 0 ) |
| 6786 |
: false; |
| 6787 |
|
| 6788 |
editorContext.is_block_editor = phpIsBlockEditor || ( isNativePostEditorUrl && Array.isArray( blocks ) && ( hasEditorDOM || hasEditorMode ) ); |
| 6789 |
|
| 6790 |
// Include post_id from the core/editor store so the brain knows which page is open |
| 6791 |
const coreEditorSelect = coreEditorSelectEarly; |
| 6792 |
if ( coreEditorSelect ) { |
| 6793 |
const postId = coreEditorSelect.getCurrentPostId(); |
| 6794 |
const postTitle = coreEditorSelect.getEditedPostAttribute( 'title' ); |
| 6795 |
const postType = coreEditorSelect.getCurrentPostType(); |
| 6796 |
if ( postId ) { |
| 6797 |
editorContext.post_id = postId; |
| 6798 |
} |
| 6799 |
if ( postTitle ) { |
| 6800 |
editorContext.post_title = postTitle; |
| 6801 |
} |
| 6802 |
if ( postType ) { |
| 6803 |
editorContext.post_type = postType; |
| 6804 |
} |
| 6805 |
} |
| 6806 |
|
| 6807 |
// Build the compact page outline (top-level sections) for AI navigation. |
| 6808 |
// Block detail is pulled on demand by the brain via editor__get_context |
| 6809 |
// (fresh clientIds + classNames), never dumped here — so no live block index. |
| 6810 |
if ( editorContext.is_block_editor && blocks.length > 0 ) { |
| 6811 |
editorContext.page_outline = this.buildPageOutline( blocks ); |
| 6812 |
} |
| 6813 |
|
| 6814 |
editorContext.snapshot_id = snapshotId; |
| 6815 |
editorContext.selection_revision = this._selectionRevision || 0; |
| 6816 |
|
| 6817 |
const selectedBlock = blockEditorSelect.getSelectedBlock(); |
| 6818 |
const hasLiveSelection = !! ( selectedBlock && selectedBlock.clientId && selectedBlock.name ); |
| 6819 |
// Explicit scope intent — replaces the brain's page-wide TEXT regex |
| 6820 |
// (computeSelectionBinding). A live block selection means the user is |
| 6821 |
// acting on THAT element, so confine the edit to it (page_wide:false); |
| 6822 |
// no selection means a page-wide edit is fine (page_wide:true). |
| 6823 |
// Deselecting is the explicit affordance to widen scope. Driven by |
| 6824 |
// REAL editor state, never the user's words — so a coincidental "all |
| 6825 |
// sections" phrase can't silently disable the selection scope lock. |
| 6826 |
// Keep in lockstep with the iframe path (src/hooks/useMessageSubmit.js). |
| 6827 |
editorContext.page_wide = ! hasLiveSelection; |
| 6828 |
if ( hasLiveSelection ) { |
| 6829 |
// Build the brain's selected_block DTO DIRECTLY in snake_case — the |
| 6830 |
// exact shape the brain's zod reads ({ client_id, block_name }). NO |
| 6831 |
// camelCase intermediate (serializeBlockLight emitted clientId/name + |
| 6832 |
// texts, all of which the brain strips) so there is no two-spelling |
| 6833 |
// drift surface to fall out of sync — the original "client_id: Required" |
| 6834 |
// turn-drop bug. Mirrors src/utils/selectedBlockDto.js, the iframe path's |
| 6835 |
// builder; keep the two in lockstep. The guard above omits the selection |
| 6836 |
// entirely when ids are absent, never a partial. |
| 6837 |
editorContext.selected_block = { |
| 6838 |
client_id: selectedBlock.clientId, |
| 6839 |
block_name: selectedBlock.name, |
| 6840 |
}; |
| 6841 |
// Repeater signal — so the brain sees "the user selected a card |
| 6842 |
// grid" and routes "add one more" to a CHILD clone (duplicateBlocks |
| 6843 |
// the last child) instead of authoring a brand-new section. Mirrors |
| 6844 |
// the page-outline heuristic (selectedBlockRepeatInfo). Iframe path |
| 6845 |
// computes the same in src/utils/selectedBlockDto.js — keep both in |
| 6846 |
// lockstep. getSelectedBlock() carries innerBlocks live this turn. |
| 6847 |
const repeatInfo = this.selectedBlockRepeatInfo( selectedBlock ); |
| 6848 |
editorContext.selected_block.repeated_children = repeatInfo.repeated_children; |
| 6849 |
editorContext.selected_block.repeated_child_count = repeatInfo.repeated_child_count; |
| 6850 |
if ( repeatInfo.last_child_client_id ) { |
| 6851 |
editorContext.selected_block.last_child_client_id = repeatInfo.last_child_client_id; |
| 6852 |
} |
| 6853 |
// NOTE: selected_block carries ONLY {client_id, block_name}. The |
| 6854 |
// brain's wire schema declares {client_id, block_name, text} and |
| 6855 |
// STRIPS anything else (wireToDomain). Two attr bundles were computed |
| 6856 |
// here every turn and silently dropped: a parent_* bundle |
| 6857 |
// (parent_client_id / parent_block_name / parent_attributes_schema / |
| 6858 |
// parent_config_attrs) and selected_block.config_attrs (pickConfigAttrs) |
| 6859 |
// — pure wasted per-turn compute + a boundary smear (plugin-side attr- |
| 6860 |
// schema interpretation that never reached the model). Exact attribute |
| 6861 |
// names are available on demand via editor__get_context. Removed (L-8). |
| 6862 |
} |
| 6863 |
|
| 6864 |
// Live theme.json design tokens (editor getSettings()) — the |
| 6865 |
// deterministic, always-present palette/type SSOT the brain styles |
| 6866 |
// against. Emitted directly in the BRAIN WIRE SHAPE |
| 6867 |
// (`theme_tokens: { colors: [{slug, hex}], font_sizes: [{slug, size}] }`) |
| 6868 |
// so Laravel relays it VERBATIM with no reshaping. The WP getSettings() |
| 6869 |
// shape is `[{name, slug, color|size}]`; we map + drop malformed entries |
| 6870 |
// here at the source. |
| 6871 |
const editorSettings = blockEditorSelect.getSettings(); |
| 6872 |
if ( editorSettings ) { |
| 6873 |
const themeTokens = buildThemeTokens( editorSettings ); |
| 6874 |
if ( themeTokens ) { |
| 6875 |
editorContext.theme_tokens = themeTokens; |
| 6876 |
} |
| 6877 |
} |
| 6878 |
|
| 6879 |
// Read-the-neighbour matcher: a representative existing section's |
| 6880 |
// ACTUALLY-RENDERED signature (heading font/colour + brand accent). |
| 6881 |
// theme_tokens/getSettings expose fixed slots, but on a multi-brand |
| 6882 |
// page the page's real brand may live in a different slot (--accent, |
| 6883 |
// not --primary) or an untokenised font — so the brain matches a new |
| 6884 |
// section to what a real neighbour RENDERS. Canvas-read, best-effort. |
| 6885 |
if ( editorContext.is_block_editor ) { |
| 6886 |
const sectionSignature = this.buildSectionSignature(); |
| 6887 |
if ( sectionSignature ) { |
| 6888 |
editorContext.section_signature = sectionSignature; |
| 6889 |
} |
| 6890 |
} |
| 6891 |
|
| 6892 |
// Inject and clear results from the previous turn's js_hook executions |
| 6893 |
if ( window.__zipwpLastToolResults && window.__zipwpLastToolResults.length > 0 ) { |
| 6894 |
editorContext.last_tool_results = window.__zipwpLastToolResults; |
| 6895 |
window.__zipwpLastToolResults = null; |
| 6896 |
} |
| 6897 |
|
| 6898 |
return editorContext; |
| 6899 |
} |
| 6900 |
|
| 6901 |
// Read-the-neighbour matcher. Sample EVERY top-level section in the live |
| 6902 |
// canvas and return the page's DOMINANT rendered signature — the most |
| 6903 |
// common heading font-family + colour, and the most common brand accent |
| 6904 |
// (button backgrounds, else link/icon colours). Reading all sections + a |
| 6905 |
// mode makes it robust to an outlier band that a single sample could land |
| 6906 |
// on. The brain resolves the accent colour to whichever site token renders |
| 6907 |
// it (so a page whose brand is --accent matches, not the stale --primary). |
| 6908 |
// Best-effort: any failure → null, so the editor-context build never |
| 6909 |
// breaks. Canvas render ≠ frontend, but accent/font resolve correctly in |
| 6910 |
// the canvas — enough to match by. |
| 6911 |
buildSectionSignature() { |
| 6912 |
try { |
| 6913 |
const shared = window.zipwpEditorShared; |
| 6914 |
const doc = shared && shared.canvasDoc ? shared.canvasDoc() : document; |
| 6915 |
const view = doc.defaultView || window; |
| 6916 |
const all = Array.prototype.slice.call( |
| 6917 |
doc.querySelectorAll( 'section, .wp-block-spectra-container' ) |
| 6918 |
).filter( ( s ) => s.offsetHeight > 160 ); |
| 6919 |
const top = all.filter( ( s ) => ! all.some( ( o ) => o !== s && o.contains( s ) ) ); |
| 6920 |
if ( ! top.length ) { |
| 6921 |
return null; |
| 6922 |
} |
| 6923 |
// Read EVERY top-level section and take the most common value per |
| 6924 |
// field (the MODE) — a single sample can land on an outlier band; |
| 6925 |
// the mode is the page's DOMINANT brand signature. Buttons are the |
| 6926 |
// clearest brand pop, so button backgrounds decide the accent and |
| 6927 |
// link/icon colours are only a fallback when no section has one. |
| 6928 |
const opaque = ( c ) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent'; |
| 6929 |
const rgbOf = ( c ) => { |
| 6930 |
const m = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec( c || '' ); |
| 6931 |
return m ? [ Number( m[ 1 ] ), Number( m[ 2 ] ), Number( m[ 3 ] ) ] : null; |
| 6932 |
}; |
| 6933 |
// A colour worth counting as a brand ACCENT: opaque AND chromatic. |
| 6934 |
// The old opaque()-only gate accepted near-neutrals (a white/black/ |
| 6935 |
// grey button, or the near-black/near-white a plain link contributes) |
| 6936 |
// and reported them as the page accent. Require real saturation in a |
| 6937 |
// mid lightness band so #000/#fff/greys never become the "brand pop". |
| 6938 |
const isBrandAccent = ( c ) => { |
| 6939 |
if ( ! opaque( c ) ) { |
| 6940 |
return false; |
| 6941 |
} |
| 6942 |
const p = rgbOf( c ); |
| 6943 |
if ( ! p ) { |
| 6944 |
return false; |
| 6945 |
} |
| 6946 |
const max = Math.max( p[ 0 ], p[ 1 ], p[ 2 ] ); |
| 6947 |
const min = Math.min( p[ 0 ], p[ 1 ], p[ 2 ] ); |
| 6948 |
const sat = max === 0 ? 0 : ( max - min ) / max; |
| 6949 |
const light = max / 255; |
| 6950 |
return sat >= 0.15 && light >= 0.12 && light <= 0.95; |
| 6951 |
}; |
| 6952 |
const fonts = {}; |
| 6953 |
const headingColors = {}; |
| 6954 |
const btnAccents = {}; |
| 6955 |
const linkAccents = {}; |
| 6956 |
const bump = ( map, key ) => { |
| 6957 |
if ( key ) { |
| 6958 |
map[ key ] = ( map[ key ] || 0 ) + 1; |
| 6959 |
} |
| 6960 |
}; |
| 6961 |
for ( let i = 0; i < top.length; i++ ) { |
| 6962 |
const sec = top[ i ]; |
| 6963 |
const h = sec.querySelector( 'h1, h2, h3' ); |
| 6964 |
if ( h ) { |
| 6965 |
const hcs = view.getComputedStyle( h ); |
| 6966 |
bump( fonts, hcs.fontFamily ); |
| 6967 |
bump( headingColors, hcs.color ); |
| 6968 |
} |
| 6969 |
const btn = sec.querySelector( '.wp-block-button__link, a.wp-block-button__link, button' ); |
| 6970 |
const btnBg = btn ? view.getComputedStyle( btn ).backgroundColor : ''; |
| 6971 |
if ( isBrandAccent( btnBg ) ) { |
| 6972 |
bump( btnAccents, btnBg ); |
| 6973 |
} else { |
| 6974 |
// Fallback: an accent-BEARING element, never a bare text link. |
| 6975 |
// The brain's global CSS sets `a { color: var(--primary) }`, so |
| 6976 |
// a plain <a> echoes --primary — the exact false signal that |
| 6977 |
// stamps a new section gold on a green-brand page. Prefer a |
| 6978 |
// button-styled link's TEXT colour (captures an outline/ghost |
| 6979 |
// CTA whose transparent bg skipped the branch above), then an |
| 6980 |
// icon; the chroma filter drops any neutral that slips through. |
| 6981 |
const pop = sec.querySelector( 'a.wp-block-button__link, i[class*="fa-"], svg' ); |
| 6982 |
const popColor = pop ? view.getComputedStyle( pop ).color : ''; |
| 6983 |
if ( isBrandAccent( popColor ) ) { |
| 6984 |
bump( linkAccents, popColor ); |
| 6985 |
} |
| 6986 |
} |
| 6987 |
} |
| 6988 |
const mode = ( map ) => { |
| 6989 |
let best = null; |
| 6990 |
let bestN = 0; |
| 6991 |
const keys = Object.keys( map ); |
| 6992 |
for ( let i = 0; i < keys.length; i++ ) { |
| 6993 |
if ( map[ keys[ i ] ] > bestN ) { |
| 6994 |
bestN = map[ keys[ i ] ]; |
| 6995 |
best = keys[ i ]; |
| 6996 |
} |
| 6997 |
} |
| 6998 |
return best; |
| 6999 |
}; |
| 7000 |
const sig = {}; |
| 7001 |
const hf = mode( fonts ); |
| 7002 |
if ( hf ) { |
| 7003 |
sig.heading_font = hf; |
| 7004 |
} |
| 7005 |
const hc = mode( headingColors ); |
| 7006 |
if ( hc ) { |
| 7007 |
sig.heading_color = hc; |
| 7008 |
} |
| 7009 |
const accent = mode( btnAccents ) || mode( linkAccents ); |
| 7010 |
if ( accent ) { |
| 7011 |
sig.accent_color = accent; |
| 7012 |
} |
| 7013 |
return Object.keys( sig ).length ? sig : null; |
| 7014 |
} catch ( e ) { |
| 7015 |
return null; |
| 7016 |
} |
| 7017 |
} |
| 7018 |
|
| 7019 |
buildPageOutline( blocks ) { |
| 7020 |
function countDeep( innerBlocks ) { |
| 7021 |
if ( ! innerBlocks || ! innerBlocks.length ) { |
| 7022 |
return 0; |
| 7023 |
} |
| 7024 |
let count = 0; |
| 7025 |
const queue = innerBlocks.slice(); |
| 7026 |
while ( queue.length > 0 ) { |
| 7027 |
const b = queue.shift(); |
| 7028 |
count++; |
| 7029 |
if ( b.innerBlocks && b.innerBlocks.length > 0 ) { |
| 7030 |
for ( let i = 0; i < b.innerBlocks.length; i++ ) { |
| 7031 |
queue.push( b.innerBlocks[ i ] ); |
| 7032 |
} |
| 7033 |
} |
| 7034 |
} |
| 7035 |
return count; |
| 7036 |
} |
| 7037 |
|
| 7038 |
function clipLabel( s ) { |
| 7039 |
return s.length > 80 ? s.substring( 0, 77 ) + '...' : s; |
| 7040 |
} |
| 7041 |
// A block's OWN representative text — LIVE TEXT_KEYS attributes ONLY. |
| 7042 |
// We deliberately do NOT fall back to block.originalContent: that is |
| 7043 |
// the parse-time serialized HTML, which goes STALE vs the live |
| 7044 |
// attributes after an in-editor edit. A stale label can steer the |
| 7045 |
// model to match the WRONG section → wrong clientId (select-X → |
| 7046 |
// operate-Y). A block with no live text attr returns null, and |
| 7047 |
// extractHeading then DESCENDS to a child that has one — so a section |
| 7048 |
// CONTAINER (no own text) is always labelled by its real heading. |
| 7049 |
function ownText( block ) { |
| 7050 |
const attrs = block && block.attributes; |
| 7051 |
if ( attrs ) { |
| 7052 |
for ( let i = 0; i < TEXT_KEYS.length; i++ ) { |
| 7053 |
const val = attrs[ TEXT_KEYS[ i ] ]; |
| 7054 |
if ( typeof val === 'string' && val.trim() ) { |
| 7055 |
const stripped = val.replace( /<[^>]*>/g, '' ).trim(); |
| 7056 |
if ( stripped ) { |
| 7057 |
return stripped; |
| 7058 |
} |
| 7059 |
} |
| 7060 |
} |
| 7061 |
} |
| 7062 |
return null; |
| 7063 |
} |
| 7064 |
// A heading-ish block: a heading block type, or a content block whose |
| 7065 |
// tag is h1-h6. Preferred as a section's label over body copy. |
| 7066 |
function isHeadingBlock( block ) { |
| 7067 |
if ( ! block || ! block.name ) { |
| 7068 |
return false; |
| 7069 |
} |
| 7070 |
if ( block.name.indexOf( 'heading' ) !== -1 ) { |
| 7071 |
return true; |
| 7072 |
} |
| 7073 |
const tag = block.attributes && ( block.attributes.tagName || block.attributes.htmlTag ); |
| 7074 |
return typeof tag === 'string' && /^h[1-6]$/i.test( tag ); |
| 7075 |
} |
| 7076 |
// A section's label. A section CONTAINER carries no text of its own — |
| 7077 |
// its identifying heading lives in a NESTED child — so when the block |
| 7078 |
// itself has no text we DESCEND (document order) and surface the first |
| 7079 |
// nested heading (h1-h6 / heading block), falling back to the first |
| 7080 |
// nested text. Without this every Spectra/FSE section reads as an |
| 7081 |
// anonymous `spectra/container` (heading: null) and the agent cannot |
| 7082 |
// tell the hero from the CTA — forcing a get_context drill per section |
| 7083 |
// (or a fabricated answer). With it, the outline is a real section map. |
| 7084 |
function extractHeading( block ) { |
| 7085 |
const own = ownText( block ); |
| 7086 |
if ( own ) { |
| 7087 |
return clipLabel( own ); |
| 7088 |
} |
| 7089 |
let firstText = null; |
| 7090 |
function walk( b ) { |
| 7091 |
if ( ! b ) { |
| 7092 |
return null; |
| 7093 |
} |
| 7094 |
const t = ownText( b ); |
| 7095 |
if ( t ) { |
| 7096 |
if ( isHeadingBlock( b ) ) { |
| 7097 |
return t; |
| 7098 |
} // best: a real heading |
| 7099 |
if ( firstText === null ) { |
| 7100 |
firstText = t; |
| 7101 |
} // fallback: first text in order |
| 7102 |
} |
| 7103 |
if ( b.innerBlocks ) { |
| 7104 |
for ( let i = 0; i < b.innerBlocks.length; i++ ) { |
| 7105 |
const r = walk( b.innerBlocks[ i ] ); |
| 7106 |
if ( r ) { |
| 7107 |
return r; |
| 7108 |
} |
| 7109 |
} |
| 7110 |
} |
| 7111 |
return null; |
| 7112 |
} |
| 7113 |
const inner = block.innerBlocks || []; |
| 7114 |
for ( let j = 0; j < inner.length; j++ ) { |
| 7115 |
const found = walk( inner[ j ] ); |
| 7116 |
if ( found ) { |
| 7117 |
return clipLabel( found ); |
| 7118 |
} |
| 7119 |
} |
| 7120 |
return firstText !== null ? clipLabel( firstText ) : null; |
| 7121 |
} |
| 7122 |
|
| 7123 |
function hasButtons( block ) { |
| 7124 |
// DFS scan for button blocks |
| 7125 |
const stack = [ block ]; |
| 7126 |
while ( stack.length > 0 ) { |
| 7127 |
const b = stack.pop(); |
| 7128 |
if ( b.name && ( b.name.indexOf( 'button' ) !== -1 || b.name.indexOf( 'cta' ) !== -1 ) ) { |
| 7129 |
return true; |
| 7130 |
} |
| 7131 |
if ( b.innerBlocks ) { |
| 7132 |
for ( let i = 0; i < b.innerBlocks.length; i++ ) { |
| 7133 |
stack.push( b.innerBlocks[ i ] ); |
| 7134 |
} |
| 7135 |
} |
| 7136 |
} |
| 7137 |
return false; |
| 7138 |
} |
| 7139 |
|
| 7140 |
function hasFormFields( block ) { |
| 7141 |
const stack = [ block ]; |
| 7142 |
while ( stack.length > 0 ) { |
| 7143 |
const b = stack.pop(); |
| 7144 |
if ( b.name && ( b.name.indexOf( 'form' ) !== -1 || b.name.indexOf( 'input' ) !== -1 || b.name.indexOf( 'field' ) !== -1 ) ) { |
| 7145 |
return true; |
| 7146 |
} |
| 7147 |
if ( b.innerBlocks ) { |
| 7148 |
for ( let i = 0; i < b.innerBlocks.length; i++ ) { |
| 7149 |
stack.push( b.innerBlocks[ i ] ); |
| 7150 |
} |
| 7151 |
} |
| 7152 |
} |
| 7153 |
return false; |
| 7154 |
} |
| 7155 |
|
| 7156 |
// --- Section surfacing ------------------------------------------ |
| 7157 |
// Spectra/FSE pages nest every visual section under one (or a chain |
| 7158 |
// of) WRAPPER container(s) whose own class is empty or purely |
| 7159 |
// layout/alignment. getBlocks() therefore hands us the wrapper, not |
| 7160 |
// the sections. Descend through such a single wrapper until we reach |
| 7161 |
// the level where the sections actually live — multiple siblings, a |
| 7162 |
// single SEMANTICALLY-classed section, or a non-container. This makes |
| 7163 |
// the outline a real section map the agent can target directly. |
| 7164 |
function blockHasText( b ) { |
| 7165 |
const a = ( b && b.attributes ) || {}; |
| 7166 |
const t = typeof a.content === 'string' ? a.content : ( typeof a.text === 'string' ? a.text : '' ); |
| 7167 |
return t.replace( /<[^>]*>/g, '' ).trim().length > 0; |
| 7168 |
} |
| 7169 |
function isContainerBlock( b ) { |
| 7170 |
return !! ( b && b.name && ( b.name.indexOf( 'container' ) !== -1 || b.name === 'core/group' || b.name === 'core/columns' ) ); |
| 7171 |
} |
| 7172 |
// A class string is "wrapper-generic" when every token is a layout / |
| 7173 |
// alignment / auto class (alignfull, is-layout-*, wp-block-*, has-*) |
| 7174 |
// — nothing SEMANTIC like zip-ai-hero. Such a container is a pass-through |
| 7175 |
// wrapper we descend past; a semantic class marks a real section. |
| 7176 |
function isGenericWrapperClass( cls ) { |
| 7177 |
if ( ! cls ) { |
| 7178 |
return true; |
| 7179 |
} |
| 7180 |
const tokens = cls.split( /\s+/ ); |
| 7181 |
for ( let k = 0; k < tokens.length; k++ ) { |
| 7182 |
const t = tokens[ k ]; |
| 7183 |
if ( t && ! /^align(full|wide|left|right|center|none)$|^is-layout-|^wp-block-|^has-/.test( t ) ) { |
| 7184 |
return false; |
| 7185 |
} |
| 7186 |
} |
| 7187 |
return true; |
| 7188 |
} |
| 7189 |
function sectionLevel( rootBlocks ) { |
| 7190 |
let level = rootBlocks || []; |
| 7191 |
for ( let hop = 0; hop < 6; hop++ ) { |
| 7192 |
// meaningful siblings = drop trailing freeform / empty spacers |
| 7193 |
// so a lone wrapper isn't masked by decorative neighbours. |
| 7194 |
const meaningful = []; |
| 7195 |
for ( let j = 0; j < level.length; j++ ) { |
| 7196 |
const b = level[ j ]; |
| 7197 |
if ( ! b || ! b.name || b.name.indexOf( 'freeform' ) !== -1 ) { |
| 7198 |
continue; |
| 7199 |
} |
| 7200 |
if ( ( b.innerBlocks && b.innerBlocks.length > 0 ) || blockHasText( b ) ) { |
| 7201 |
meaningful.push( b ); |
| 7202 |
} |
| 7203 |
} |
| 7204 |
if ( meaningful.length !== 1 ) { |
| 7205 |
break; |
| 7206 |
} // 0 or 2+ siblings → this IS the section level |
| 7207 |
const only = meaningful[ 0 ]; |
| 7208 |
const cls = ( only.attributes && typeof only.attributes.className === 'string' ? only.attributes.className : '' ).trim(); |
| 7209 |
const canDescend = isContainerBlock( only ) && isGenericWrapperClass( cls ) && only.innerBlocks && only.innerBlocks.length > 0; |
| 7210 |
if ( ! canDescend ) { |
| 7211 |
break; |
| 7212 |
} // semantic-classed container = a section; non-container = stop |
| 7213 |
level = only.innerBlocks; |
| 7214 |
} |
| 7215 |
return level.length > 0 ? level : ( rootBlocks || [] ); |
| 7216 |
} |
| 7217 |
|
| 7218 |
// Emits the BRAIN WIRE SHAPE for each outline row directly (snake_case |
| 7219 |
// `client_id`, optional fields OMITTED rather than null). Laravel relays |
| 7220 |
// page_outline VERBATIM — it does no renaming, hand-picking, or default |
| 7221 |
// injection. The brain's EditorSchema (wireSchemas.ts) is the sole |
| 7222 |
// validator: `heading_excerpt` is z.string().min(1).optional() so it must |
| 7223 |
// be ABSENT (not null) when there's no heading. |
| 7224 |
return sectionLevel( blocks ).map( function( block ) { |
| 7225 |
// Repeated card-like children (3+ of one block type) — shared |
| 7226 |
// detection with the selected-block repeater signal. |
| 7227 |
const repeated = dominantRepeatedChildType( block.innerBlocks || [] ) !== null; |
| 7228 |
|
| 7229 |
const row = { |
| 7230 |
client_id: block.clientId, |
| 7231 |
block_name: block.name, |
| 7232 |
// Section's current class string — so the brain can restyle a |
| 7233 |
// section (className REPLACES wholesale, so it needs the |
| 7234 |
// existing classes to keep) DIRECTLY from the outline, without |
| 7235 |
// a get_context round-trip. |
| 7236 |
class_name: ( block.attributes && typeof block.attributes.className === 'string' ) |
| 7237 |
? block.attributes.className : '', |
| 7238 |
inner_block_count: countDeep( block.innerBlocks ), |
| 7239 |
has_buttons: hasButtons( block ), |
| 7240 |
has_form_fields: hasFormFields( block ), |
| 7241 |
repeated_children: repeated, |
| 7242 |
}; |
| 7243 |
|
| 7244 |
// Optional hint — present only when the section actually carries a |
| 7245 |
// heading. Omitted (never null) to satisfy the brain's optional() schema. |
| 7246 |
const heading = extractHeading( block ); |
| 7247 |
if ( heading ) { |
| 7248 |
row.heading_excerpt = heading; |
| 7249 |
} |
| 7250 |
|
| 7251 |
return row; |
| 7252 |
} ); |
| 7253 |
} |
| 7254 |
|
| 7255 |
// Repeater shape of the SELECTED block, in the brain wire shape, via the |
| 7256 |
// shared dominantRepeatedChildType detection. The clone target is the |
| 7257 |
// last child OF THE DOMINANT type (never a trailing CTA / spacer of a |
| 7258 |
// different type), and the count is that type's occurrence count (the |
| 7259 |
// number of cards), not the deep descendant total. Kept in lockstep with |
| 7260 |
// the iframe builder src/utils/selectedBlockDto.js::repeatInfoFromInnerBlocks. |
| 7261 |
selectedBlockRepeatInfo( block ) { |
| 7262 |
const children = ( block && block.innerBlocks ) || []; |
| 7263 |
let dominant = dominantRepeatedChildType( children ); |
| 7264 |
let gridChildren = children; |
| 7265 |
if ( dominant === null ) { |
| 7266 |
const nested = nestedGrid( children ); |
| 7267 |
if ( nested !== null ) { |
| 7268 |
dominant = nested.dominant; |
| 7269 |
gridChildren = nested.children; |
| 7270 |
} |
| 7271 |
} |
| 7272 |
if ( dominant === null ) { |
| 7273 |
return { repeated_children: false, repeated_child_count: 0 }; |
| 7274 |
} |
| 7275 |
|
| 7276 |
const info = { repeated_children: true, repeated_child_count: dominant.count }; |
| 7277 |
for ( let i = gridChildren.length - 1; i >= 0; i-- ) { |
| 7278 |
const c = gridChildren[ i ]; |
| 7279 |
if ( c && c.name === dominant.name && typeof c.clientId === 'string' && c.clientId !== '' ) { |
| 7280 |
info.last_child_client_id = c.clientId; |
| 7281 |
break; |
| 7282 |
} |
| 7283 |
} |
| 7284 |
return info; |
| 7285 |
} |
| 7286 |
|
| 7287 |
getPageContext() { |
| 7288 |
const phpPageContext = this.config.pageContext || {}; |
| 7289 |
return { |
| 7290 |
post_id: phpPageContext.post_id || null, |
| 7291 |
post_type: phpPageContext.post_type || null, |
| 7292 |
post_title: phpPageContext.post_title || null, |
| 7293 |
post_status: phpPageContext.post_status || null, |
| 7294 |
}; |
| 7295 |
} |
| 7296 |
|
| 7297 |
getAdminScreenContext() { |
| 7298 |
const phpContext = window.zipwpMcpContext || {}; |
| 7299 |
const currentScreen = phpContext.currentScreen || {}; |
| 7300 |
return { |
| 7301 |
id: currentScreen.id || null, |
| 7302 |
base: currentScreen.base || null, |
| 7303 |
post_type: currentScreen.post_type || null, |
| 7304 |
action: currentScreen.action || null, |
| 7305 |
parent_base: currentScreen.parent_base || null, |
| 7306 |
is_admin: phpContext.isAdmin || false, |
| 7307 |
}; |
| 7308 |
} |
| 7309 |
|
| 7310 |
// ── Block Serialization ─────────────────────────────────────── |
| 7311 |
|
| 7312 |
serializeBlock( block ) { |
| 7313 |
if ( ! block ) { |
| 7314 |
return null; |
| 7315 |
} |
| 7316 |
|
| 7317 |
let blocksHtml = ''; |
| 7318 |
if ( window.wp && window.wp.blocks && window.wp.blocks.serialize ) { |
| 7319 |
try { |
| 7320 |
blocksHtml = window.wp.blocks.serialize( [ block ] ); |
| 7321 |
blocksHtml = this.fixUnicodeEscapes( blocksHtml ); |
| 7322 |
} catch ( e ) { |
| 7323 |
console.warn( 'Failed to serialize block:', e ); |
| 7324 |
} |
| 7325 |
} |
| 7326 |
|
| 7327 |
const self = this; |
| 7328 |
return { |
| 7329 |
clientId: block.clientId, |
| 7330 |
name: block.name, |
| 7331 |
blockName: block.name, |
| 7332 |
attributes: block.attributes, |
| 7333 |
attrs: block.attributes, |
| 7334 |
innerBlocks: ( block.innerBlocks || [] ).map( function( innerBlock ) { |
| 7335 |
return self.serializeBlock( innerBlock ); |
| 7336 |
} ), |
| 7337 |
blocks_html: blocksHtml, |
| 7338 |
}; |
| 7339 |
} |
| 7340 |
|
| 7341 |
fixUnicodeEscapes( html ) { |
| 7342 |
if ( ! html || typeof html !== 'string' ) { |
| 7343 |
return html; |
| 7344 |
} |
| 7345 |
return html |
| 7346 |
.replace( /\\\\u002d/gi, '-' ) |
| 7347 |
.replace( /\\u002d/gi, '-' ) |
| 7348 |
.replace( /u002d/gi, '-' ); |
| 7349 |
} |
| 7350 |
|
| 7351 |
// ── Block Editor Operations ─────────────────────────────────── |
| 7352 |
|
| 7353 |
captureBlockScreenshot( clientId, options ) { |
| 7354 |
if ( ! clientId ) { |
| 7355 |
return Promise.reject( new Error( 'Block clientId is required' ) ); |
| 7356 |
} |
| 7357 |
|
| 7358 |
const utils = window.zipwpMcpSpectraUtils; |
| 7359 |
if ( ! utils || ! utils.captureBlockScreenshot ) { |
| 7360 |
return Promise.reject( new Error( 'Screenshot utility not available' ) ); |
| 7361 |
} |
| 7362 |
|
| 7363 |
return utils.captureBlockScreenshot( clientId, options || {} ).then( function( screenshot ) { |
| 7364 |
if ( ! screenshot ) { |
| 7365 |
throw new Error( 'Failed to capture screenshot' ); |
| 7366 |
} |
| 7367 |
return { |
| 7368 |
success: true, |
| 7369 |
base64: screenshot.base64, |
| 7370 |
width: screenshot.width, |
| 7371 |
height: screenshot.height, |
| 7372 |
}; |
| 7373 |
} ); |
| 7374 |
} |
| 7375 |
|
| 7376 |
/** |
| 7377 |
* Capture a screenshot of the editor content area. |
| 7378 |
* |
| 7379 |
* Uses html2canvas to render the editor styles wrapper (or fallback targets) |
| 7380 |
* and returns a base64 JPEG data URL (quality 0.7, max width 1280px). |
| 7381 |
* |
| 7382 |
* @since x.x.x |
| 7383 |
* @return {Promise<Object>} { success, dataUrl, width, height } or { success: false, error } |
| 7384 |
*/ |
| 7385 |
async captureScreenshot() { |
| 7386 |
try { |
| 7387 |
// Find the best element to capture |
| 7388 |
const target = document.querySelector( '.editor-styles-wrapper' ) || |
| 7389 |
document.querySelector( '.block-editor-block-list__layout' ) || |
| 7390 |
document.querySelector( '#wpwrap' ); |
| 7391 |
|
| 7392 |
if ( ! target ) { |
| 7393 |
return { success: false, error: 'No editor content found' }; |
| 7394 |
} |
| 7395 |
|
| 7396 |
// html2canvas is available via the WP plugin |
| 7397 |
let html2canvas = window.html2canvas; |
| 7398 |
if ( ! html2canvas ) { |
| 7399 |
// Try dynamic import |
| 7400 |
try { |
| 7401 |
const mod = await import( 'html2canvas' ); |
| 7402 |
html2canvas = mod.default || mod; |
| 7403 |
} catch ( e ) { |
| 7404 |
return { success: false, error: 'html2canvas not available' }; |
| 7405 |
} |
| 7406 |
} |
| 7407 |
|
| 7408 |
const canvas = await html2canvas( target, { |
| 7409 |
useCORS: true, |
| 7410 |
allowTaint: true, |
| 7411 |
scale: 1, |
| 7412 |
logging: false, |
| 7413 |
windowWidth: target.scrollWidth, |
| 7414 |
windowHeight: target.scrollHeight, |
| 7415 |
} ); |
| 7416 |
|
| 7417 |
// Resize if too large |
| 7418 |
const maxWidth = 1280; |
| 7419 |
let finalCanvas = canvas; |
| 7420 |
if ( canvas.width > maxWidth ) { |
| 7421 |
finalCanvas = document.createElement( 'canvas' ); |
| 7422 |
const ratio = maxWidth / canvas.width; |
| 7423 |
finalCanvas.width = maxWidth; |
| 7424 |
finalCanvas.height = Math.round( canvas.height * ratio ); |
| 7425 |
const ctx = finalCanvas.getContext( '2d' ); |
| 7426 |
ctx.drawImage( canvas, 0, 0, finalCanvas.width, finalCanvas.height ); |
| 7427 |
} |
| 7428 |
|
| 7429 |
return { |
| 7430 |
success: true, |
| 7431 |
dataUrl: finalCanvas.toDataURL( 'image/jpeg', 0.7 ), |
| 7432 |
width: finalCanvas.width, |
| 7433 |
height: finalCanvas.height, |
| 7434 |
}; |
| 7435 |
} catch ( e ) { |
| 7436 |
return { success: false, error: e.message || 'Screenshot capture failed' }; |
| 7437 |
} |
| 7438 |
} |
| 7439 |
|
| 7440 |
// ── Tool Execution ──────────────────────────────────────────── |
| 7441 |
|
| 7442 |
// `sessionId` (M1) — the chat session that dispatched these calls; the |
| 7443 |
// React SSE layer passes it so js_rpc replies can carry it to Laravel, |
| 7444 |
// which verifies it against the brain's owner key for the call_id. |
| 7445 |
async executeTools( executionResults, sessionId ) { |
| 7446 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7447 |
console.log( '[ZIP AI:wp-bridge-host] executeTools called count=%d tools=%s', |
| 7448 |
executionResults.length, |
| 7449 |
executionResults.map( ( r ) => r.js_handler || r.tool_name ).join( ', ' ) ); |
| 7450 |
} |
| 7451 |
|
| 7452 |
for ( let i = 0; i < executionResults.length; i++ ) { |
| 7453 |
const result = executionResults[ i ]; |
| 7454 |
if ( result.execution_mode === 'js_hook' || result.execution_mode === 'hybrid' || result.execution_mode === 'js_rpc' ) { |
| 7455 |
try { |
| 7456 |
// eslint-disable-next-line no-var |
| 7457 |
var toolName = result.js_handler || result.tool_name; |
| 7458 |
const toolArguments = result.arguments || {}; |
| 7459 |
// Anthropic-assigned tool_use_id, stamped by the |
| 7460 |
// SSE handler before this dispatch. Carried onto |
| 7461 |
// window.__zipwpLastToolResults so the brain's |
| 7462 |
// jsHookReconciler can attribute the next-turn |
| 7463 |
// result deterministically to the originating |
| 7464 |
// todo via todo.dispatched_call_ids. |
| 7465 |
// eslint-disable-next-line no-var |
| 7466 |
var toolCallId = result.call_id || null; |
| 7467 |
|
| 7468 |
// B-1 / P5 idempotency: decide BEFORE running the handler. |
| 7469 |
// The pure decision (core/rpc-dedup.js, unit-tested) reads |
| 7470 |
// the prior _rpcSeen entry: a prior entry (in-flight OR |
| 7471 |
// completed) → NEVER re-dispatch (that double-applies) — |
| 7472 |
// re-POST it so a waiting brain BRPOP resolves; a first run |
| 7473 |
// → record the IN-FLIGHT marker NOW (before the handler |
| 7474 |
// mutates the tree) so a crash mid-apply still blocks a |
| 7475 |
// replay (the replay reposts an uncertain ok:false reply → |
| 7476 |
// the brain verifies before retrying). Degrades to the |
| 7477 |
// pre-P5 seen-check if the helper module isn't loaded (no |
| 7478 |
// crash-window marker) — never a duplicate of the logic. |
| 7479 |
if ( result.execution_mode === 'js_rpc' ) { |
| 7480 |
const seenReply = rpcSeenGet( toolCallId ); |
| 7481 |
const dedupApi = ( typeof window !== 'undefined' ) ? window.zipwpRpcDedup : null; |
| 7482 |
const dedup = ( dedupApi && dedupApi.decideJsRpcDispatch ) |
| 7483 |
? dedupApi.decideJsRpcDispatch( seenReply, PAGE_LOAD_ID ) |
| 7484 |
: { action: seenReply ? 'repost_cached' : 'run', reply: seenReply, inFlightMarker: null }; |
| 7485 |
if ( dedup.action === 'repost_cached' ) { |
| 7486 |
console.warn( '[ZIP AI:wp-bridge-host] js_rpc duplicate/in-flight call_id=%s — skipping re-dispatch, re-POSTing reply', toolCallId ); |
| 7487 |
await this.postRpcReply( toolCallId, dedup.reply.ok, dedup.reply.data, dedup.reply.error, sessionId ); |
| 7488 |
continue; |
| 7489 |
} |
| 7490 |
if ( dedup.inFlightMarker ) { |
| 7491 |
const inflight = dedup.inFlightMarker; |
| 7492 |
rpcSeenRemember( toolCallId, inflight.ok, inflight.data, inflight.error ); |
| 7493 |
} |
| 7494 |
} |
| 7495 |
|
| 7496 |
const hasHandler = window.zipwpMcp && window.zipwpMcp.toolHooks && |
| 7497 |
window.zipwpMcp.toolHooks.hasHandler( toolName ); |
| 7498 |
|
| 7499 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7500 |
console.log( '[ZIP AI:wp-bridge-host] executing tool=%s hasHandler=%s zipwpMcp=%s', |
| 7501 |
toolName, hasHandler, !! ( window.zipwpMcp ) ); |
| 7502 |
} |
| 7503 |
|
| 7504 |
// [RTRACE] BRIDGE-IN — the js_rpc envelope arrives at the browser bridge |
| 7505 |
// and the registered handler is about to be invoked with the wire args. |
| 7506 |
if ( result.execution_mode === 'js_rpc' ) { |
| 7507 |
try { |
| 7508 |
// attr_keys is the load-bearing signal: a key the |
| 7509 |
// target block doesn't define (e.g. tagName on a |
| 7510 |
// spectra/container, which uses htmlTag) is silently |
| 7511 |
// dropped by the registry → the model never sees the |
| 7512 |
// edit "complete" and re-tries. Surface it at the JS |
| 7513 |
// hop so the loop is visible from the browser too. |
| 7514 |
const _ops = ( toolArguments && Array.isArray( toolArguments.operations ) ) ? toolArguments.operations : []; |
| 7515 |
const _attrKeys = _ops.reduce( function ( acc, o ) { |
| 7516 |
if ( o && o.attributes && typeof o.attributes === 'object' ) { |
| 7517 |
acc.push.apply( acc, Object.keys( o.attributes ) ); |
| 7518 |
} |
| 7519 |
return acc; |
| 7520 |
}, [] ); |
| 7521 |
( window.__zipwpTrace = window.__zipwpTrace || [] ).push( { |
| 7522 |
hop: 'bridge:receive+invoke', ts: Date.now(), |
| 7523 |
tool_name: toolName, call_id: toolCallId, |
| 7524 |
execution_mode: result.execution_mode, has_handler: !! hasHandler, |
| 7525 |
version: toolArguments && toolArguments.version, |
| 7526 |
post_id: toolArguments && toolArguments.post_id, |
| 7527 |
scope: toolArguments && toolArguments.scope, |
| 7528 |
op_count: _ops.length, |
| 7529 |
functions: _ops.map( function ( o ) { |
| 7530 |
return o && o.function; |
| 7531 |
} ), |
| 7532 |
attr_keys: _attrKeys.length ? _attrKeys : null, |
| 7533 |
} ); |
| 7534 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7535 |
console.log( '[RTRACE] bridge:receive+invoke tool=%s call_id=%s ops=%d attr_keys=%s', toolName, toolCallId, _ops.length, JSON.stringify( _attrKeys ) ); |
| 7536 |
} |
| 7537 |
} catch ( e ) { /* trace never breaks dispatch */ } |
| 7538 |
} |
| 7539 |
|
| 7540 |
if ( ! hasHandler ) { |
| 7541 |
throw new Error( 'No handler available for tool: ' + toolName ); |
| 7542 |
} |
| 7543 |
|
| 7544 |
// L-16: the full-page getContext() re-walk was merged |
| 7545 |
// into every envelope as `_wordpress_context` — but NO |
| 7546 |
// editor handler reads it (the brain strips it at the |
| 7547 |
// wire). Dropped the per-envelope re-walk; pass the wire |
| 7548 |
// args through directly. |
| 7549 |
// eslint-disable-next-line no-var |
| 7550 |
var toolArgs = Object.assign( {}, toolArguments ); |
| 7551 |
|
| 7552 |
const hookResult = await window.zipwpMcp.toolHooks.executeToolHook( |
| 7553 |
toolName, |
| 7554 |
toolArgs, |
| 7555 |
{ jsExecutionResult: executionResults } |
| 7556 |
); |
| 7557 |
|
| 7558 |
// Capture result for next-turn context injection |
| 7559 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7560 |
console.log( '[ZIP AI:wp-bridge-host] handler finished tool=%s hookResult=%s', toolName, JSON.stringify( hookResult ) ); |
| 7561 |
} |
| 7562 |
|
| 7563 |
// Vibe Editing v2 — synchronous RPC: POST the reply so the brain's |
| 7564 |
// AgentBrowserLoop (blocked on BRPOP for this call_id) resolves the |
| 7565 |
// call IN THE SAME TURN, then skip the next-turn buffer below. |
| 7566 |
if ( result.execution_mode === 'js_rpc' ) { |
| 7567 |
const rpcOk = !! ( hookResult && hookResult.success !== false ); |
| 7568 |
const rpcData = hookResult ? hookResult.data : undefined; |
| 7569 |
const rpcErr = hookResult ? ( hookResult.error || null ) : 'js_rpc handler returned no result'; |
| 7570 |
// [RTRACE] BRIDGE-OUT — what wp.data actually produced, |
| 7571 |
// before the reply goes back to the brain. The reply |
| 7572 |
// summary (applied / failed / unknown_attrs) is what the |
| 7573 |
// model reacts to next step; unknown_attrs here is the |
| 7574 |
// browser-side proof of a silent registry drop. |
| 7575 |
try { |
| 7576 |
const _ap = ( rpcData && Array.isArray( rpcData.applied ) ) ? rpcData.applied : []; |
| 7577 |
const _ua = _ap.reduce( function ( acc, a ) { |
| 7578 |
if ( a && Array.isArray( a.unknown_attrs ) ) { |
| 7579 |
acc.push.apply( acc, a.unknown_attrs ); |
| 7580 |
} |
| 7581 |
return acc; |
| 7582 |
}, [] ); |
| 7583 |
( window.__zipwpTrace = window.__zipwpTrace || [] ).push( { |
| 7584 |
hop: 'bridge:reply', ts: Date.now(), |
| 7585 |
tool_name: toolName, call_id: toolCallId, ok: rpcOk, |
| 7586 |
applied: _ap.length, |
| 7587 |
failed: ( rpcData && Array.isArray( rpcData.failed ) ) ? rpcData.failed.length : null, |
| 7588 |
unknown_attrs: _ua.length ? _ua : null, |
| 7589 |
blocks: ( rpcData && Array.isArray( rpcData.blocks ) ) ? rpcData.blocks.length : null, |
| 7590 |
error: rpcErr, |
| 7591 |
} ); |
| 7592 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7593 |
console.log( '[RTRACE] bridge:reply tool=%s call_id=%s ok=%s applied=%d unknown_attrs=%s', toolName, toolCallId, rpcOk, _ap.length, JSON.stringify( _ua ) ); |
| 7594 |
} |
| 7595 |
} catch ( e ) { /* trace never breaks dispatch */ } |
| 7596 |
// UPGRADE the in-flight marker to the REAL reply, and |
| 7597 |
// before posting so a replay during the POST round-trip |
| 7598 |
// still de-dups against the final reply (B-1 / P5). |
| 7599 |
rpcSeenRemember( toolCallId, rpcOk, rpcData, rpcErr, { |
| 7600 |
mutating: rpcOk === true && SESSION_SCOPED_TOOLS[ toolName ] === true, |
| 7601 |
pageLoadId: PAGE_LOAD_ID, |
| 7602 |
} ); |
| 7603 |
await this.postRpcReply( toolCallId, rpcOk, rpcData, rpcErr, sessionId ); |
| 7604 |
continue; |
| 7605 |
} |
| 7606 |
|
| 7607 |
if ( ! window.__zipwpLastToolResults ) { |
| 7608 |
window.__zipwpLastToolResults = []; |
| 7609 |
} |
| 7610 |
if ( hookResult && typeof hookResult === 'object' ) { |
| 7611 |
window.__zipwpLastToolResults.push( { |
| 7612 |
tool: toolName, |
| 7613 |
// Primary correlation: Anthropic tool_use_id assigned by the LLM, |
| 7614 |
// plumbed end-to-end. The brain's jsHookReconciler matches this |
| 7615 |
// against todo.dispatched_call_ids — deterministic, identity-based. |
| 7616 |
call_id: toolCallId, |
| 7617 |
// Fallback correlation: original tool arguments. Used when |
| 7618 |
// call_id is missing (legacy bundle or non-LLM-initiated paths) |
| 7619 |
// — the reconciler subset-matches against todo.tool_call.arguments. |
| 7620 |
args: toolArgs || null, |
| 7621 |
success: hookResult.success !== false, |
| 7622 |
message: hookResult.message || null, |
| 7623 |
user_summary: hookResult.user_summary || null, |
| 7624 |
operation: hookResult.operation || null, |
| 7625 |
verification: hookResult.verification || hookResult.data?.verification || null, |
| 7626 |
error: hookResult.error || null, |
| 7627 |
} ); |
| 7628 |
} else { |
| 7629 |
console.warn( '[ZIP AI:wp-bridge-host] js_hook returned null/undefined for tool=%s — result NOT captured in last_tool_results', toolName ); |
| 7630 |
window.__zipwpLastToolResults.push( { |
| 7631 |
tool: toolName, |
| 7632 |
call_id: toolCallId, |
| 7633 |
args: toolArgs || null, |
| 7634 |
success: false, |
| 7635 |
error: 'js_hook handler returned no result', |
| 7636 |
} ); |
| 7637 |
} |
| 7638 |
} catch ( error ) { |
| 7639 |
console.error( '[ZIP AI:wp-bridge-host] handler threw for tool=%s error=%s', toolName, error?.message, error ); |
| 7640 |
if ( result.execution_mode === 'js_rpc' ) { |
| 7641 |
// A thrown handler may still have partially mutated |
| 7642 |
// the tree, so de-dup this call_id too (B-1). |
| 7643 |
const thrownErr = error?.message || 'js_rpc handler threw'; |
| 7644 |
// A session-scoped tool that THREW may still have partially |
| 7645 |
// mutated the tree — tag it mutating so a mid-turn reload |
| 7646 |
// replays RELOAD_REVERIFY_MARKER (re-verify against the |
| 7647 |
// discarded state), not the stale thrown error verbatim. |
| 7648 |
rpcSeenRemember( toolCallId, false, undefined, thrownErr, { |
| 7649 |
mutating: SESSION_SCOPED_TOOLS[ toolName ] === true, |
| 7650 |
} ); |
| 7651 |
await this.postRpcReply( toolCallId, false, undefined, thrownErr, sessionId ); |
| 7652 |
continue; |
| 7653 |
} |
| 7654 |
if ( ! window.__zipwpLastToolResults ) { |
| 7655 |
window.__zipwpLastToolResults = []; |
| 7656 |
} |
| 7657 |
window.__zipwpLastToolResults.push( { |
| 7658 |
tool: toolName, |
| 7659 |
call_id: toolCallId, |
| 7660 |
args: toolArgs || null, |
| 7661 |
success: false, |
| 7662 |
error: error?.message || 'js_hook threw an exception', |
| 7663 |
} ); |
| 7664 |
} |
| 7665 |
} |
| 7666 |
} |
| 7667 |
} |
| 7668 |
|
| 7669 |
// Vibe Editing v2 — POST a synchronous editor-RPC reply to the SaaS so |
| 7670 |
// the brain's AgentBrowserLoop (blocked on BRPOP for this call_id) |
| 7671 |
// resolves the tool in the SAME turn. Auth + base URL come from |
| 7672 |
// window.ZIPAI_CONFIG (the same source the React app's api client uses). |
| 7673 |
// |
| 7674 |
// `sessionId` (M1) — included as session_id so Laravel can verify the |
| 7675 |
// reply against the brain-claimed owner of this call_id (the owner key's |
| 7676 |
// VALUE is the dispatching session). Without it any authenticated tenant |
| 7677 |
// holding a call_id could inject a reply into a foreign turn. |
| 7678 |
// |
| 7679 |
// H1 — bounded retry. The reply is the ONLY confirmation the brain gets |
| 7680 |
// and there is no later resend, so a transient failure (network blip, |
| 7681 |
// 5xx, or a 409 from a not-yet-claimed owner key) must not drop it |
| 7682 |
// permanently: every dropped reply costs the brain a full BRPOP window |
| 7683 |
// plus a verify round-trip. Retries are safe by construction — Laravel |
| 7684 |
// RPUSHes at most one consumable copy per POST and the brain releases |
| 7685 |
// the owner key after consuming, so a duplicate late POST is 409-rejected, |
| 7686 |
// never double-folded; re-execution is impossible (the handler already |
| 7687 |
// ran; _rpcSeen replays the cached reply). |
| 7688 |
async postRpcReply( callId, ok, data, error, sessionId ) { |
| 7689 |
// The editor turn's executeTools loop AWAITS this POST before moving |
| 7690 |
// to the next call, so a hung request would stall the whole turn. |
| 7691 |
// Bound it with an AbortController timeout — the brain's BRPOP times |
| 7692 |
// out independently on its side, so dropping a slow reply is safe. |
| 7693 |
// |
| 7694 |
// B-1 INVARIANT: total time here (attempts × timeout + backoff) MUST |
| 7695 |
// stay BELOW the brain's BRAIN_EDITOR_RPC_TIMEOUT_MS (default 20s). |
| 7696 |
// The plugin applies the change BEFORE replying, so if the brain |
| 7697 |
// gives up first it reads a false "nothing applied" and may nudge a |
| 7698 |
// retry → duplicate content. Per-attempt timeout 5s × 3 + backoff |
| 7699 |
// ≈ 15.9s worst case, ~4s inside the 20s window (F6: widened margin). |
| 7700 |
const cfg = window.ZIPAI_CONFIG || {}; |
| 7701 |
const attempts = 3; |
| 7702 |
const backoffMs = 300; |
| 7703 |
// F6 — cap the per-attempt timeout so the TOTAL reply budget stays safely |
| 7704 |
// under the brain's BRPOP window even if an operator sets a large |
| 7705 |
// rpcReplyTimeoutMs. The old 6s default left only ~1s of headroom, and the |
| 7706 |
// offset between the brain STARTING its wait and this reply arriving |
| 7707 |
// (handler-exec + SSE + the reply route's ownership check) ate it — so a |
| 7708 |
// reply that actually succeeded landed after the brain gave up and was |
| 7709 |
// read as a FALSE timeout (the edit had already applied). 5s keeps the |
| 7710 |
// total ≈ 15.9s, ~4s inside the window. |
| 7711 |
const perAttemptMax = 5000; |
| 7712 |
const configured = Number( cfg.rpcReplyTimeoutMs ) > 0 ? Number( cfg.rpcReplyTimeoutMs ) : perAttemptMax; |
| 7713 |
const timeoutMs = Math.min( configured, perAttemptMax ); |
| 7714 |
const apiUrl = ( cfg.apiUrl || '/api' ).replace( /\/+$/, '' ); |
| 7715 |
// Route the reply DIRECT to the brain when configured (same switch |
| 7716 |
// as the React api client's brainPath); else fall back to Laravel. |
| 7717 |
const rpcReplyUrl = ( cfg.brainUrl ? cfg.brainUrl.replace( /\/+$/, '' ) : apiUrl ) + '/agent/rpc-reply'; |
| 7718 |
const body = { call_id: callId, ok: !! ok }; |
| 7719 |
if ( sessionId ) { |
| 7720 |
body.session_id = String( sessionId ); |
| 7721 |
} |
| 7722 |
if ( data !== undefined && data !== null ) { |
| 7723 |
body.data = data; |
| 7724 |
} |
| 7725 |
if ( error ) { |
| 7726 |
body.error = String( error ); |
| 7727 |
} |
| 7728 |
const headers = { 'Content-Type': 'application/json', Accept: 'application/json' }; |
| 7729 |
if ( cfg.token ) { |
| 7730 |
headers.Authorization = 'Bearer ' + cfg.token; |
| 7731 |
} |
| 7732 |
const payload = JSON.stringify( body ); |
| 7733 |
|
| 7734 |
// [RTRACE] BRIDGE-OUT — the reply POSTs DIRECT to the brain (brainUrl), |
| 7735 |
// whose AgentBrowserLoop is blocked on BRPOP for this call_id. Clearing |
| 7736 |
// brainUrl reverts to the Laravel path (plugin-wide direct-to-brain toggle). |
| 7737 |
try { |
| 7738 |
( window.__zipwpTrace = window.__zipwpTrace || [] ).push( { |
| 7739 |
hop: 'bridge:postRpcReply', ts: Date.now(), |
| 7740 |
call_id: callId, ok: !! ok, |
| 7741 |
endpoint: rpcReplyUrl, |
| 7742 |
applied: ( data && data.applied ) ? data.applied.length : null, |
| 7743 |
failed: ( data && data.failed ) ? data.failed.length : null, |
| 7744 |
refused: ( data && data.refused ) ? data.refused : null, |
| 7745 |
error: error ? String( error ) : null, |
| 7746 |
} ); |
| 7747 |
if ( window.ZIPAI_CONFIG && window.ZIPAI_CONFIG.debug ) { |
| 7748 |
console.log( '[RTRACE] bridge:postRpcReply call_id=%s ok=%s', callId, !! ok ); |
| 7749 |
} |
| 7750 |
} catch ( e ) { /* trace never breaks reply */ } |
| 7751 |
|
| 7752 |
// eslint-disable-next-line no-var |
| 7753 |
for ( var attempt = 1; attempt <= attempts; attempt++ ) { |
| 7754 |
// eslint-disable-next-line no-var |
| 7755 |
var controller = ( typeof AbortController !== 'undefined' ) ? new AbortController() : null; |
| 7756 |
const timer = controller ? setTimeout( function () { |
| 7757 |
controller.abort(); |
| 7758 |
}, timeoutMs ) : null; |
| 7759 |
try { |
| 7760 |
const opts = { method: 'POST', headers, body: payload }; |
| 7761 |
if ( controller ) { |
| 7762 |
opts.signal = controller.signal; |
| 7763 |
} |
| 7764 |
const res = await fetch( rpcReplyUrl, opts ); |
| 7765 |
if ( res.ok ) { |
| 7766 |
return; |
| 7767 |
} |
| 7768 |
// 4xx other than 409 won't improve on retry (bad payload / |
| 7769 |
// auth); 409 can be the claim race or an already-consumed |
| 7770 |
// reply — retry covers the former, the latter stays 409 and |
| 7771 |
// we stop after the budget. |
| 7772 |
const retryable = res.status === 409 || res.status >= 500; |
| 7773 |
console.error( '[ZIP AI:wp-bridge-host] rpc-reply POST failed status=%d call_id=%s attempt=%d/%d', |
| 7774 |
res.status, callId, attempt, attempts ); |
| 7775 |
if ( ! retryable ) { |
| 7776 |
return; |
| 7777 |
} |
| 7778 |
} catch ( e ) { |
| 7779 |
const reason = ( e && e.name === 'AbortError' ) ? ( 'timeout after ' + timeoutMs + 'ms' ) : ( e && e.message ); |
| 7780 |
console.error( '[ZIP AI:wp-bridge-host] rpc-reply POST threw call_id=%s attempt=%d/%d err=%s', |
| 7781 |
callId, attempt, attempts, reason ); |
| 7782 |
} finally { |
| 7783 |
if ( timer ) { |
| 7784 |
clearTimeout( timer ); |
| 7785 |
} |
| 7786 |
} |
| 7787 |
if ( attempt < attempts ) { |
| 7788 |
await new Promise( function ( r ) { |
| 7789 |
setTimeout( r, backoffMs * attempt ); |
| 7790 |
} ); |
| 7791 |
} |
| 7792 |
} |
| 7793 |
} |
| 7794 |
|
| 7795 |
// ── Canvas Loader Protocol ──────────────────────────────────── |
| 7796 |
|
| 7797 |
applyCanvasLoader( toolName, target ) { |
| 7798 |
if ( ! target ) { |
| 7799 |
return; |
| 7800 |
} |
| 7801 |
|
| 7802 |
switch ( target.type ) { |
| 7803 |
case 'block': |
| 7804 |
this._applyBlockLoader( target.id ); |
| 7805 |
break; |
| 7806 |
case 'selector': |
| 7807 |
this._applySelectorLoader( target.selector ); |
| 7808 |
break; |
| 7809 |
case 'global': |
| 7810 |
this._applyGlobalLoader( toolName ); |
| 7811 |
break; |
| 7812 |
case 'fullpage': |
| 7813 |
this._applyFullPageLoader(); |
| 7814 |
break; |
| 7815 |
} |
| 7816 |
} |
| 7817 |
|
| 7818 |
// eslint-disable-next-line no-unused-vars |
| 7819 |
removeCanvasLoader( toolName, target, success ) { |
| 7820 |
if ( ! target ) { |
| 7821 |
return; |
| 7822 |
} |
| 7823 |
|
| 7824 |
switch ( target.type ) { |
| 7825 |
case 'block': |
| 7826 |
this._removeBlockLoader( target.id ); |
| 7827 |
break; |
| 7828 |
case 'selector': |
| 7829 |
this._removeSelectorLoader( target.selector ); |
| 7830 |
break; |
| 7831 |
case 'global': |
| 7832 |
this._removeGlobalLoader(); |
| 7833 |
break; |
| 7834 |
case 'fullpage': |
| 7835 |
this._removeFullPageLoader(); |
| 7836 |
break; |
| 7837 |
} |
| 7838 |
} |
| 7839 |
|
| 7840 |
_applyBlockLoader( clientId ) { |
| 7841 |
if ( ! clientId ) { |
| 7842 |
return; |
| 7843 |
} |
| 7844 |
const blockNode = document.querySelector( '[data-block="' + clientId + '"]' ); |
| 7845 |
if ( blockNode ) { |
| 7846 |
blockNode.classList.add( 'is-ai-processing' ); |
| 7847 |
} |
| 7848 |
|
| 7849 |
if ( window.wp && window.wp.data ) { |
| 7850 |
try { |
| 7851 |
window.wp.data.dispatch( 'core/block-editor' ) |
| 7852 |
.updateBlockAttributes( clientId, { lock: { move: true, remove: true } } ); |
| 7853 |
} catch ( e ) { /* block may not support lock */ } |
| 7854 |
} |
| 7855 |
} |
| 7856 |
|
| 7857 |
_removeBlockLoader( clientId ) { |
| 7858 |
if ( ! clientId ) { |
| 7859 |
return; |
| 7860 |
} |
| 7861 |
const blockNode = document.querySelector( '[data-block="' + clientId + '"]' ); |
| 7862 |
if ( blockNode ) { |
| 7863 |
blockNode.classList.remove( 'is-ai-processing' ); |
| 7864 |
} |
| 7865 |
|
| 7866 |
if ( window.wp && window.wp.data ) { |
| 7867 |
try { |
| 7868 |
window.wp.data.dispatch( 'core/block-editor' ) |
| 7869 |
.updateBlockAttributes( clientId, { lock: undefined } ); |
| 7870 |
} catch ( e ) { /* silent */ } |
| 7871 |
} |
| 7872 |
} |
| 7873 |
|
| 7874 |
_applySelectorLoader( selector ) { |
| 7875 |
try { |
| 7876 |
const el = document.querySelector( selector ); |
| 7877 |
if ( el ) { |
| 7878 |
el.classList.add( 'is-ai-processing' ); |
| 7879 |
} |
| 7880 |
} catch ( e ) { /* invalid selector */ } |
| 7881 |
} |
| 7882 |
|
| 7883 |
_removeSelectorLoader( selector ) { |
| 7884 |
try { |
| 7885 |
const el = document.querySelector( selector ); |
| 7886 |
if ( el ) { |
| 7887 |
el.classList.remove( 'is-ai-processing' ); |
| 7888 |
} |
| 7889 |
} catch ( e ) { /* silent */ } |
| 7890 |
} |
| 7891 |
|
| 7892 |
_applyGlobalLoader() { |
| 7893 |
if ( document.getElementById( 'ai-global-loader' ) ) { |
| 7894 |
return; |
| 7895 |
} |
| 7896 |
const toast = document.createElement( 'div' ); |
| 7897 |
toast.id = 'ai-global-loader'; |
| 7898 |
toast.className = 'ai-global-processing-toast'; |
| 7899 |
toast.textContent = 'AI is updating design settings\u2026'; |
| 7900 |
document.body.appendChild( toast ); |
| 7901 |
} |
| 7902 |
|
| 7903 |
_removeGlobalLoader() { |
| 7904 |
const toast = document.getElementById( 'ai-global-loader' ); |
| 7905 |
if ( toast ) { |
| 7906 |
toast.remove(); |
| 7907 |
} |
| 7908 |
} |
| 7909 |
|
| 7910 |
_applyFullPageLoader() { |
| 7911 |
if ( document.getElementById( 'ai-fullpage-overlay' ) ) { |
| 7912 |
return; |
| 7913 |
} |
| 7914 |
const overlay = document.createElement( 'div' ); |
| 7915 |
overlay.id = 'ai-fullpage-overlay'; |
| 7916 |
overlay.className = 'ai-fullpage-processing-overlay'; |
| 7917 |
const text = document.createElement( 'div' ); |
| 7918 |
text.className = 'ai-fullpage-text'; |
| 7919 |
text.textContent = 'AI is building your page\u2026'; |
| 7920 |
overlay.appendChild( text ); |
| 7921 |
document.body.appendChild( overlay ); |
| 7922 |
} |
| 7923 |
|
| 7924 |
_removeFullPageLoader() { |
| 7925 |
const overlay = document.getElementById( 'ai-fullpage-overlay' ); |
| 7926 |
if ( overlay ) { |
| 7927 |
overlay.remove(); |
| 7928 |
} |
| 7929 |
} |
| 7930 |
|
| 7931 |
// ── Color Palette Preview ───────────────────────────────────── |
| 7932 |
|
| 7933 |
previewPalette( colors ) { |
| 7934 |
if ( ! colors || ! Array.isArray( colors ) ) { |
| 7935 |
return; |
| 7936 |
} |
| 7937 |
|
| 7938 |
let styleEl = document.getElementById( 'zipwp-palette-preview' ); |
| 7939 |
if ( ! styleEl ) { |
| 7940 |
styleEl = document.createElement( 'style' ); |
| 7941 |
styleEl.id = 'zipwp-palette-preview'; |
| 7942 |
document.head.appendChild( styleEl ); |
| 7943 |
} |
| 7944 |
|
| 7945 |
const vars = colors.map( function( c, i ) { |
| 7946 |
const slug = ( typeof c === 'object' && c.slug ) || ( 'ast-global-color-' + i ); |
| 7947 |
const color = typeof c === 'string' ? c : ( c.color || c.hex || '' ); |
| 7948 |
return '--' + slug + ':' + color; |
| 7949 |
} ).join( ';' ); |
| 7950 |
|
| 7951 |
styleEl.textContent = ':root{' + vars + '}'; |
| 7952 |
} |
| 7953 |
|
| 7954 |
// ── Auth ────────────────────────────────────────────────────── |
| 7955 |
|
| 7956 |
checkAuthStatus() { |
| 7957 |
const self = this; |
| 7958 |
const formData = new URLSearchParams(); |
| 7959 |
formData.append( 'action', 'zipwp_verify_auth_status' ); |
| 7960 |
formData.append( 'nonce', self.config.nonce ); |
| 7961 |
return fetch( self.config.ajaxUrl, { |
| 7962 |
method: 'POST', |
| 7963 |
credentials: 'same-origin', |
| 7964 |
body: formData, |
| 7965 |
} ) |
| 7966 |
.then( function( response ) { |
| 7967 |
return response.json(); |
| 7968 |
} ) |
| 7969 |
.then( function( result ) { |
| 7970 |
return !! ( result && result.success && result.data && result.data.is_authorized ); |
| 7971 |
} ) |
| 7972 |
.catch( function() { |
| 7973 |
return false; |
| 7974 |
} ); |
| 7975 |
} |
| 7976 |
|
| 7977 |
openAuthPopup() { |
| 7978 |
const self = this; |
| 7979 |
return new Promise( function( resolve, reject ) { |
| 7980 |
const url = self.config.authUrl; |
| 7981 |
if ( ! url || ! self.config.ajaxUrl || ! self.config.nonce ) { |
| 7982 |
reject( new Error( 'Assistant auth configuration is missing. Reload the page and try again.' ) ); |
| 7983 |
return; |
| 7984 |
} |
| 7985 |
|
| 7986 |
const width = 600; |
| 7987 |
const height = 700; |
| 7988 |
const left = ( screen.width - width ) / 2; |
| 7989 |
const top = ( screen.height - height ) / 2; |
| 7990 |
|
| 7991 |
const popupWindow = window.open( |
| 7992 |
url, |
| 7993 |
'ZipWP Login', |
| 7994 |
'width=' + width + ',height=' + height + ',top=' + top + ',left=' + left + ',popup=yes,toolbar=no,location=no,menubar=no' |
| 7995 |
); |
| 7996 |
|
| 7997 |
if ( ! popupWindow || popupWindow.closed ) { |
| 7998 |
reject( new Error( 'Popup was blocked. Please allow popups for this site and try again.' ) ); |
| 7999 |
return; |
| 8000 |
} |
| 8001 |
|
| 8002 |
let iterations = 0; |
| 8003 |
const maxIterations = 300; |
| 8004 |
|
| 8005 |
// eslint-disable-next-line no-var |
| 8006 |
var authPollingInterval = setInterval( async function() { |
| 8007 |
if ( popupWindow.closed || iterations >= maxIterations ) { |
| 8008 |
clearInterval( authPollingInterval ); |
| 8009 |
if ( ! popupWindow.closed ) { |
| 8010 |
popupWindow.close(); |
| 8011 |
} |
| 8012 |
// The popup can close (ZipWP auto-closes on success, or the |
| 8013 |
// user closes it) between two 500ms polls — before a tick |
| 8014 |
// catches is_authorized. Do one final check so a completed |
| 8015 |
// login still reloads into the authed app instead of |
| 8016 |
// stranding the user on the auth screen. |
| 8017 |
if ( popupWindow.closed && await self.checkAuthStatus() ) { |
| 8018 |
self.reloadWithAutoOpen(); |
| 8019 |
return; |
| 8020 |
} |
| 8021 |
if ( iterations >= maxIterations ) { |
| 8022 |
reject( new Error( 'Authentication timeout' ) ); |
| 8023 |
} |
| 8024 |
return; |
| 8025 |
} |
| 8026 |
|
| 8027 |
if ( await self.checkAuthStatus() ) { |
| 8028 |
if ( ! popupWindow.closed ) { |
| 8029 |
popupWindow.close(); |
| 8030 |
} |
| 8031 |
clearInterval( authPollingInterval ); |
| 8032 |
self.reloadWithAutoOpen(); |
| 8033 |
} |
| 8034 |
|
| 8035 |
iterations++; |
| 8036 |
}, 500 ); |
| 8037 |
} ); |
| 8038 |
} |
| 8039 |
|
| 8040 |
// ── Inline Edit Shortcut (Cmd+J / Ctrl+J) ──────────────────── |
| 8041 |
|
| 8042 |
/** |
| 8043 |
* Register Cmd+J / Ctrl+J keyboard shortcut to open sidebar and focus chat input. |
| 8044 |
* |
| 8045 |
* @since x.x.x |
| 8046 |
*/ |
| 8047 |
setupInlineEditShortcut() { |
| 8048 |
const self = this; |
| 8049 |
const handler = function( e ) { |
| 8050 |
const isMac = navigator.platform.toUpperCase().indexOf( 'MAC' ) >= 0; |
| 8051 |
const modKey = isMac ? e.metaKey : e.ctrlKey; |
| 8052 |
|
| 8053 |
if ( modKey && e.key === 'j' ) { |
| 8054 |
e.preventDefault(); |
| 8055 |
e.stopPropagation(); |
| 8056 |
|
| 8057 |
// Open sidebar if not visible |
| 8058 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 8059 |
if ( container && ! container.classList.contains( 'zip-ai-iframe-visible' ) ) { |
| 8060 |
self.togglePanel(); |
| 8061 |
} |
| 8062 |
|
| 8063 |
// Emit event for React to focus input and lock context |
| 8064 |
if ( window.zipwpMcpAppBridge ) { |
| 8065 |
window.zipwpMcpAppBridge.emit( 'inline_edit_shortcut', {} ); |
| 8066 |
} |
| 8067 |
} |
| 8068 |
}; |
| 8069 |
|
| 8070 |
// Listen on main document |
| 8071 |
document.addEventListener( 'keydown', handler, true ); |
| 8072 |
|
| 8073 |
// Also listen inside the block editor iframe (WP 6.3+) |
| 8074 |
const attachToEditorIframe = function() { |
| 8075 |
const iframes = document.querySelectorAll( 'iframe[name="editor-canvas"]' ); |
| 8076 |
iframes.forEach( function( iframe ) { |
| 8077 |
try { |
| 8078 |
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; |
| 8079 |
if ( iframeDoc && ! iframeDoc._zipwpShortcutAttached ) { |
| 8080 |
iframeDoc.addEventListener( 'keydown', handler, true ); |
| 8081 |
iframeDoc._zipwpShortcutAttached = true; |
| 8082 |
} |
| 8083 |
} catch ( e ) { /* cross-origin iframe, skip */ } |
| 8084 |
} ); |
| 8085 |
}; |
| 8086 |
|
| 8087 |
// Retry attaching since editor iframe loads asynchronously |
| 8088 |
setTimeout( attachToEditorIframe, 1000 ); |
| 8089 |
setTimeout( attachToEditorIframe, 3000 ); |
| 8090 |
} |
| 8091 |
|
| 8092 |
// ── Fullscreen ──────────────────────────────────────────────── |
| 8093 |
|
| 8094 |
emitFullscreenChanged( isFullscreen ) { |
| 8095 |
if ( window.zipwpMcpAppBridge && typeof window.zipwpMcpAppBridge.emit === 'function' ) { |
| 8096 |
window.zipwpMcpAppBridge.emit( 'fullscreen_changed', { fullscreen: !! isFullscreen } ); |
| 8097 |
} |
| 8098 |
} |
| 8099 |
|
| 8100 |
toggleFullscreen( isFullscreen ) { |
| 8101 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 8102 |
if ( ! container ) { |
| 8103 |
return; |
| 8104 |
} |
| 8105 |
|
| 8106 |
if ( isFullscreen ) { |
| 8107 |
container.classList.add( 'zip-ai-iframe-fullscreen' ); |
| 8108 |
document.body.classList.add( 'zip-ai-assistant-fullscreen' ); |
| 8109 |
// Legacy alias for older selectors/scripts. |
| 8110 |
document.body.classList.add( 'zip-ai-iframe-fullscreen' ); |
| 8111 |
} else { |
| 8112 |
container.classList.remove( 'zip-ai-iframe-fullscreen' ); |
| 8113 |
document.body.classList.remove( 'zip-ai-assistant-fullscreen' ); |
| 8114 |
document.body.classList.remove( 'zip-ai-iframe-fullscreen' ); |
| 8115 |
} |
| 8116 |
|
| 8117 |
this.emitFullscreenChanged( isFullscreen ); |
| 8118 |
} |
| 8119 |
|
| 8120 |
// ── Resize Handle ───────────────────────────────────────────── |
| 8121 |
|
| 8122 |
setupResizeHandle() { |
| 8123 |
const resizeHandle = document.getElementById( 'zip-ai-resize-handle' ); |
| 8124 |
if ( ! resizeHandle ) { |
| 8125 |
return; |
| 8126 |
} |
| 8127 |
|
| 8128 |
let isDragging = false; |
| 8129 |
let startX = 0; |
| 8130 |
let startWidth = 0; |
| 8131 |
const self = this; |
| 8132 |
|
| 8133 |
const onMouseDown = function( e ) { |
| 8134 |
e.preventDefault(); |
| 8135 |
isDragging = true; |
| 8136 |
startX = e.clientX; |
| 8137 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 8138 |
startWidth = container ? container.offsetWidth : 550; |
| 8139 |
|
| 8140 |
resizeHandle.classList.add( 'dragging' ); |
| 8141 |
document.body.classList.add( 'zip-ai-resizing' ); |
| 8142 |
|
| 8143 |
document.addEventListener( 'mousemove', onMouseMove ); |
| 8144 |
document.addEventListener( 'mouseup', onMouseUp ); |
| 8145 |
}; |
| 8146 |
|
| 8147 |
// eslint-disable-next-line no-var |
| 8148 |
var onMouseMove = function( e ) { |
| 8149 |
if ( ! isDragging ) { |
| 8150 |
return; |
| 8151 |
} |
| 8152 |
const delta = startX - e.clientX; |
| 8153 |
const newWidth = startWidth + delta; |
| 8154 |
self.resizeSidebar( newWidth ); |
| 8155 |
}; |
| 8156 |
|
| 8157 |
// eslint-disable-next-line no-var |
| 8158 |
var onMouseUp = function() { |
| 8159 |
if ( ! isDragging ) { |
| 8160 |
return; |
| 8161 |
} |
| 8162 |
|
| 8163 |
isDragging = false; |
| 8164 |
resizeHandle.classList.remove( 'dragging' ); |
| 8165 |
document.body.classList.remove( 'zip-ai-resizing' ); |
| 8166 |
|
| 8167 |
document.removeEventListener( 'mousemove', onMouseMove ); |
| 8168 |
document.removeEventListener( 'mouseup', onMouseUp ); |
| 8169 |
|
| 8170 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 8171 |
if ( container ) { |
| 8172 |
self.saveSidebarWidth( container.offsetWidth ); |
| 8173 |
} |
| 8174 |
}; |
| 8175 |
|
| 8176 |
resizeHandle.addEventListener( 'mousedown', onMouseDown ); |
| 8177 |
|
| 8178 |
resizeHandle.addEventListener( 'touchstart', function( e ) { |
| 8179 |
const touch = e.touches[ 0 ]; |
| 8180 |
onMouseDown( { clientX: touch.clientX, preventDefault() { |
| 8181 |
e.preventDefault(); |
| 8182 |
} } ); |
| 8183 |
} ); |
| 8184 |
|
| 8185 |
document.addEventListener( 'touchmove', function( e ) { |
| 8186 |
if ( ! isDragging ) { |
| 8187 |
return; |
| 8188 |
} |
| 8189 |
const touch = e.touches[ 0 ]; |
| 8190 |
onMouseMove( { clientX: touch.clientX } ); |
| 8191 |
} ); |
| 8192 |
|
| 8193 |
document.addEventListener( 'touchend', onMouseUp ); |
| 8194 |
} |
| 8195 |
|
| 8196 |
resizeSidebar( width ) { |
| 8197 |
const minWidth = 360; |
| 8198 |
const maxWidth = Math.round( window.innerWidth * 0.8 ); |
| 8199 |
const clampedWidth = Math.min( maxWidth, Math.max( minWidth, width ) ); |
| 8200 |
|
| 8201 |
document.documentElement.style.setProperty( '--zipwp-sidebar-width', clampedWidth + 'px' ); |
| 8202 |
|
| 8203 |
const container = document.getElementById( 'zip-ai-assistant-container' ); |
| 8204 |
if ( container ) { |
| 8205 |
container.style.width = clampedWidth + 'px'; |
| 8206 |
} |
| 8207 |
} |
| 8208 |
|
| 8209 |
saveSidebarWidth( width ) { |
| 8210 |
const minWidth = 360; |
| 8211 |
const maxWidth = Math.round( window.innerWidth * 0.8 ); |
| 8212 |
const clampedWidth = Math.min( maxWidth, Math.max( minWidth, width ) ); |
| 8213 |
|
| 8214 |
localStorage.setItem( 'zipwp-sidebar-width', clampedWidth.toString() ); |
| 8215 |
this.resizeSidebar( clampedWidth ); |
| 8216 |
} |
| 8217 |
|
| 8218 |
loadSavedSidebarWidth() { |
| 8219 |
const savedWidth = localStorage.getItem( 'zipwp-sidebar-width' ); |
| 8220 |
if ( savedWidth ) { |
| 8221 |
this.resizeSidebar( parseInt( savedWidth, 10 ) ); |
| 8222 |
} |
| 8223 |
} |
| 8224 |
|
| 8225 |
// ── Generic Message Handler ─────────────────────────────────── |
| 8226 |
|
| 8227 |
// eslint-disable-next-line no-unused-vars |
| 8228 |
handleMessage( type, data ) { |
| 8229 |
// Extensibility point for future message types |
| 8230 |
} |
| 8231 |
} |
| 8232 |
|
| 8233 |
// Initialize and expose globally |
| 8234 |
const bridgeHost = new WPBridgeHost(); |
| 8235 |
window.zipwpMcpBridge = bridgeHost; |
| 8236 |
}() ); |
| 8237 |
|