PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.14
ZIP AI – AI Website Builder & AI Agent v0.0.14
0.0.14 0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / assets / js / tools / editor / shared / editor-shared-utils.js

editor-shared-utils.js in ZIP AI – AI Website Builder & AI Agent 0.0.14, at assets/js/tools/editor/shared/editor-shared-utils.js

630 lines 29.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * ZipWP MCP — Vibe Editing v2: shared editor utilities.
3 *
4 * The ONE source of truth for helpers the editor handlers (get-context,
5 * apply-change, get/set-scripts, get/set-styles) need identically — so they can
6 * never drift:
7 * - currentPostId: the live post id from the editor store, with the same
8 * null-guard. apply-change uses it for the two-tab post_id guard;
9 * get-context returns it in the response.
10 * - editorSelect / editorDispatch: the null-guarded `core/editor` store
11 * select/dispatch accessors. styles read the editing session through these
12 * (getEditedPostAttribute / editPost).
13 * - currentMeta: the live post meta object from the session (or {}), the
14 * read side of the session-scoped styles write.
15 * - blockEditorSelect / blockEditorDispatch / rootClientId: the null-guarded
16 * `core/block-editor` store accessors + the page root container's clientId.
17 * get/set-scripts read/patch a block's `spectraCustomJS` attribute through
18 * these (getBlockAttributes / updateBlockAttributes).
19 * - bannedVisualAttrs / isBannedVisualAttr: the 8 GBS-banned per-block visual
20 * styling props (style, *Color(Hover), boxShadow(Hover), styleAttributes) —
21 * styling lives in `className` ONLY (the Spectra GBS JIT grammar). apply-change
22 * strips them from an incoming attrs write; get-context omits them from the
23 * authorable attr-keys it advertises. ONE list so the two can never drift
24 * (mirrors Laravel's StrictAttrValidator). The 8 props are a sanctioned
25 * constant, not an allowlist — every OTHER attr is decided by the registry.
26 * - isJsCapable: is `spectraCustomJS` actually a registered attribute on this
27 * block type? spectra-blocks-pro only registers it on spectra/spectra-pro
28 * blocks + core/image + core/heading (global-styles/helpers.js
29 * SUPPORTED_BLOCKS) — writing it anywhere else paints the session but is
30 * dropped on Save. Read LIVE off `wp.blocks.getBlockType`, not a mirrored
31 * name list, so it can't drift from whatever the plugin actually registered.
32 *
33 * Dual-mode: attaches to window.zipwpEditorShared in the browser; CommonJS
34 * export for jest.
35 *
36 * Load order: enqueued before the editor handlers in source mode
37 * (react-manager.php) and concatenated ahead of handler.js in the production
38 * grunt bundle (matches the `tools/**\/*-utils.js` glob). The handlers also
39 * resolve it lazily at call time, so a missing global degrades consistently
40 * (post id -> null = the two-tab guard no-ops, exactly as a null
41 * getCurrentPostId() would).
42 *
43 * @package
44 */
45 ( function () {
46 'use strict';
47
48 function editorSelect() {
49 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select )
50 ? window.wp.data.select( 'core/editor' )
51 : null;
52 }
53
54 function editorDispatch() {
55 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch )
56 ? window.wp.data.dispatch( 'core/editor' )
57 : null;
58 }
59
60 function currentPostId() {
61 const editor = editorSelect();
62 return editor && typeof editor.getCurrentPostId === 'function'
63 ? editor.getCurrentPostId()
64 : null;
65 }
66
67 // SEC-2 two-tab guard, shared by every handler that writes the open post: a
68 // brain-stamped post id the live editor cannot confirm is a refusal (fail
69 // closed). Returns null when the write may proceed.
70 function postIdRefusal( expectedPostId ) {
71 // eslint-disable-next-line eqeqeq
72 if ( expectedPostId == null ) {
73 return null;
74 }
75 const live = currentPostId();
76 // eslint-disable-next-line eqeqeq
77 if ( live == null ) {
78 return { reason: 'post_id_unresolvable', detail: 'expected=' + expectedPostId + ',live=null', live: null };
79 }
80 if ( Number( live ) !== Number( expectedPostId ) ) {
81 return { reason: 'post_id_mismatch', detail: 'expected=' + expectedPostId + ',live=' + live, live };
82 }
83 return null;
84 }
85
86 // The live post meta object from the editing session (or {} when absent) —
87 // the read side of the session-scoped scripts/styles write.
88 function currentMeta( sel ) {
89 const meta = sel && sel.getEditedPostAttribute ? sel.getEditedPostAttribute( 'meta' ) : null;
90 return meta && typeof meta === 'object' ? meta : {};
91 }
92
93 // The null-guarded `core/block-editor` store — the read/write side of a
94 // block's attributes (get/set-scripts operate on `spectraCustomJS`, an attr).
95 // Edits through it are inherently session-scoped: in-memory now, persisted on
96 // Save, discarded with the session (same model apply-change uses).
97 function blockEditorSelect() {
98 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.select )
99 ? window.wp.data.select( 'core/block-editor' )
100 : null;
101 }
102 function blockEditorDispatch() {
103 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.dispatch )
104 ? window.wp.data.dispatch( 'core/block-editor' )
105 : null;
106 }
107
108 // The page root container's clientId — the first top-level block — the
109 // default owner for page-wide JS when no clientId is passed. null when the
110 // tree is empty or the store is absent.
111 function rootClientId( sel ) {
112 const order = sel && sel.getBlockOrder ? sel.getBlockOrder() : null;
113 return Array.isArray( order ) && order.length > 0 ? order[ 0 ] : null;
114 }
115
116 // canEditBlock reads lock.edit only. Content shared with other pages (a
117 // synced pattern's or template part's inner blocks, an overridable one
118 // included) is off-limits to an AI op even when no lock attribute says so;
119 // the synced pattern / template part block ITSELF stays editable as a page
120 // block. Editing mode follows the editor: 'disabled' takes nothing (in
121 // zoom-out, every block below a section), 'contentOnly' (an inserted
122 // pattern's blocks, a focused section, a section in zoom-out) takes
123 // attribute and text edits but no structural change.
124 const SHARED_CONTENT_HOSTS = { 'core/block': true, 'core/template-part': true };
125 function insideSharedContent( sel, id ) {
126 if ( typeof sel.getBlockParents !== 'function' ) {
127 return false;
128 }
129 return ( sel.getBlockParents( id ) || [] ).some( function ( p ) {
130 const b = sel.getBlock( p );
131 return !! ( b && SHARED_CONTENT_HOSTS[ b.name ] );
132 } );
133 }
134 // `structural` = insert / remove / move / replace (anything but an edit of
135 // the block's own attributes).
136 function isAiEditable( sel, id, structural ) {
137 if ( insideSharedContent( sel, id ) ) {
138 return false;
139 }
140 const mode = typeof sel.getBlockEditingMode === 'function' ? sel.getBlockEditingMode( id ) : 'default';
141 if ( mode === 'disabled' ) {
142 return false;
143 }
144 return ! ( structural && mode === 'contentOnly' );
145 }
146 // A container accepts AI-added (or moved-in) children unless it is shared
147 // content, sits inside it, or is disabled. A contentOnly root still takes
148 // them, as in core: with "Show template" on, core/post-content itself is
149 // contentOnly and every page section lives under it.
150 function isAiInsertableRoot( sel, root ) {
151 if ( ! root ) {
152 return true;
153 }
154 const b = sel.getBlock( root );
155 if ( b && SHARED_CONTENT_HOSTS[ b.name ] ) {
156 return false;
157 }
158 return isAiEditable( sel, root, false );
159 }
160
161 // The 13 GBS-banned per-block visual styling props. Per-block styling lives in
162 // `className` ONLY (the Spectra GBS JIT grammar); these are never authorable
163 // attrs even when a block's registry declares them. A sanctioned constant —
164 // every OTHER attribute's validity is decided by the registry (getBlockType).
165 //
166 // HARDCODED MIRROR of config/spectra-contract.json `banned_attrs.keys` in the
167 // api-scs-credits-system repo (the SSOT the brain zod + Laravel validator read).
168 // Kept in lockstep by hand for now; a generated artifact + cross-repo CI pin is
169 // the sanctioned fix (drift here is otherwise silent — apply_change attrs bypass
170 // Laravel's validator, so this client strip is the editor's only write-point guard
171 // besides the brain denylist). Per the contract's `notes.update_ops`, the full
172 // 13-key ban applies name-agnostically on the clientId-targeted apply_change path
173 // (no block name at validation time); insert/replace BlockSpecs carry a `name` and
174 // are scope-aware (srfm/* exempt) — that scope-awareness is a separate follow-up.
175 const bannedVisualAttrs = [
176 'style', 'styleAttributes',
177 'backgroundColor', 'backgroundColorHover',
178 'boxShadow', 'boxShadowHover',
179 'textColor', 'textColorHover',
180 'numberColor', 'numberColorHover',
181 'borderHover', 'iconColorHover',
182 'backgroundGradientHover',
183 ];
184 function isBannedVisualAttr( key ) {
185 return bannedVisualAttrs.indexOf( key ) !== -1;
186 }
187
188 // True when this block type actually persists `spectraCustomJS` (the
189 // registry, not a mirrored name list — see the file header).
190 function isJsCapable( blockName ) {
191 const blocks = ( typeof window !== 'undefined' && window.wp && window.wp.blocks ) ? window.wp.blocks : null;
192 if ( ! blocks || typeof blocks.getBlockType !== 'function' || typeof blockName !== 'string' ) {
193 return false;
194 }
195 const blockType = blocks.getBlockType( blockName );
196 return !! ( blockType && blockType.attributes &&
197 Object.prototype.hasOwnProperty.call( blockType.attributes, 'spectraCustomJS' ) );
198 }
199
200 // ── Dependents (reverse edges) ──────────────────────────────────────────
201 // The editor resolves OWNERSHIP (which layer owns a property) but never
202 // DEPENDENTS ("who else relies on the resource I'm about to change"). These
203 // pure helpers compute a mutating write's blast radius from the LIVE tree —
204 // no persistent index — so a destructive/shared write can surface its impact
205 // as a structured signal (act-first: the model informs the user, it doesn't
206 // silently break things). SSOT here so every handler reads dependents identically.
207
208 // Every id a block's JS references — `getElementById('x')` or a `#x` inside a
209 // querySelector. Shared by set-scripts' WRITE-time dead-#id gate AND
210 // apply-change's DELETE-time orphan-JS surface, so the two never drift on how
211 // JS is parsed.
212 function referencedIds( code ) {
213 const ids = [];
214 if ( typeof code !== 'string' || code === '' ) {
215 return ids;
216 }
217 let m;
218 const reGid = /getElementById\(\s*['"]([A-Za-z][\w-]*)['"]\s*\)/g;
219 while ( ( m = reGid.exec( code ) ) !== null ) {
220 ids.push( m[ 1 ] );
221 }
222 const reQs = /querySelector(?:All)?\(\s*(['"])([^'"]*)\1/g;
223 while ( ( m = reQs.exec( code ) ) !== null ) {
224 // eslint-disable-next-line no-var
225 var idm,
226 reId = /#([A-Za-z][\w-]*)/g;
227 while ( ( idm = reId.exec( m[ 2 ] ) ) !== null ) {
228 ids.push( idm[ 1 ] );
229 }
230 }
231 return ids;
232 }
233
234 // Every clientId in the live tree (top-level + descendants).
235 function allClientIds( sel ) {
236 return ( sel && sel.getClientIdsWithDescendants ) ? sel.getClientIdsWithDescendants() : [];
237 }
238
239 // Blocks (excluding `excludeIds`) whose `className` carries `gsToken` — the
240 // reverse `class -> blocks` edge. Answers "editing this gs- class body also
241 // restyles N other sections" BEFORE the write.
242 function classDependents( sel, gsToken, excludeIds ) {
243 const out = [];
244 if ( ! sel || ! gsToken || ! sel.getBlockAttributes ) {
245 return out;
246 }
247 const skip = Object.create( null );
248 ( excludeIds || [] ).forEach( function ( id ) {
249 skip[ id ] = true;
250 } );
251 allClientIds( sel ).forEach( function ( cid ) {
252 if ( skip[ cid ] ) {
253 return;
254 }
255 const a = sel.getBlockAttributes( cid );
256 const cn = ( a && typeof a.className === 'string' ) ? a.className : '';
257 if ( cn.split( /\s+/ ).indexOf( gsToken ) !== -1 ) {
258 out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null } );
259 }
260 } );
261 return out;
262 }
263
264 // Blocks (excluding `excludeIds`) whose `spectraCustomJS` references any of
265 // `anchors` (by `#id` / getElementById) — the reverse `anchor -> scripts`
266 // edge. Answers "deleting this block orphans the JS on N others" BEFORE the
267 // delete (their getElementById(...) would return null and THROW, killing the
268 // whole per-block IIFE — not a clean no-op).
269 function anchorDependents( sel, anchors, excludeIds ) {
270 const out = [];
271 if ( ! sel || ! anchors || ! anchors.length || ! sel.getBlockAttributes ) {
272 return out;
273 }
274 const want = Object.create( null );
275 anchors.forEach( function ( x ) {
276 if ( x ) {
277 want[ x ] = true;
278 }
279 } );
280 const skip = Object.create( null );
281 ( excludeIds || [] ).forEach( function ( id ) {
282 skip[ id ] = true;
283 } );
284 allClientIds( sel ).forEach( function ( cid ) {
285 if ( skip[ cid ] ) {
286 return;
287 }
288 const a = sel.getBlockAttributes( cid );
289 const js = ( a && typeof a.spectraCustomJS === 'string' ) ? a.spectraCustomJS : '';
290 if ( ! js ) {
291 return;
292 }
293 const hits = referencedIds( js ).filter( function ( id ) {
294 return want[ id ];
295 } );
296 if ( hits.length ) {
297 out.push( { clientId: cid, blockName: sel.getBlockName ? sel.getBlockName( cid ) : null, refs: hits } );
298 }
299 } );
300 return out;
301 }
302
303 // ── GBS page-store persist (SSOT) ──────────────────────────────────────────
304 // ONE read→merge→write→render→inject path over the per-page GBS store, shared
305 // by editor/set-styles (the styling tool) AND editor/apply-change (which uses
306 // it to persist a generated section's semantic-token class bodies — the JIT
307 // cannot synthesize gs-* class bodies, so without a store write the section
308 // renders unstyled). `apiFetch` is injected by the caller (its own wp.apiFetch
309 // wrapper); NS is the GBS route root.
310 const GBS_NS = '/spectra-blocks/v1/global-styles';
311
312 // Deep-merge one incoming schema-v1 payload onto an existing one, bucket by
313 // bucket (null value = delete). Never full-replaces — importer chrome / other
314 // sections' classes in untouched buckets survive.
315 function mergePayload( existing, incoming ) {
316 const out = Object.assign( {}, existing || {} );
317 out.v = '1';
318 Object.keys( incoming || {} ).forEach( function ( bucket ) {
319 if ( bucket === 'v' ) {
320 return;
321 }
322 const inc = incoming[ bucket ];
323 if ( inc === null ) {
324 delete out[ bucket ];
325 return;
326 }
327 if ( Array.isArray( inc ) ) {
328 out[ bucket ] = inc.slice();
329 return;
330 }
331 if ( typeof inc !== 'object' ) {
332 return;
333 }
334 const base = ( out[ bucket ] && typeof out[ bucket ] === 'object' && ! Array.isArray( out[ bucket ] ) )
335 ? Object.assign( {}, out[ bucket ] )
336 : {};
337 Object.keys( inc ).forEach( function ( key ) {
338 if ( inc[ key ] === null ) {
339 delete base[ key ];
340 } else {
341 base[ key ] = inc[ key ];
342 }
343 } );
344 out[ bucket ] = base;
345 } );
346 return out;
347 }
348
349 // The block-editor canvas runs in an iframe; styles must be injected THERE.
350 function canvasDoc() {
351 const ifr = document.querySelector( 'iframe[name="editor-canvas"]' );
352 return ifr && ifr.contentDocument ? ifr.contentDocument : document;
353 }
354 function injectCss( elementId, css ) {
355 const doc = canvasDoc();
356 let el = doc.getElementById( elementId );
357 if ( ! el ) {
358 el = doc.createElement( 'style' );
359 el.id = elementId;
360 ( doc.head || doc.documentElement ).appendChild( el );
361 }
362 el.textContent = css || '';
363 }
364
365 // Tag a REST failure with which of the three GBS steps it came from so a
366 // caller can label the outcome granularly. `step` is 'read' | 'write' |
367 // 'render' — a render failure means the data WAS saved, only the live paint
368 // failed (recoverable by reload), which is a different remediation than a
369 // read/write failure where nothing persisted. The message is preserved.
370 function taggedGbsError( step, e ) {
371 const err = e instanceof Error ? e : new Error( String( e && e.message ? e.message : e ) );
372 err.gbsStep = step;
373 return err;
374 }
375
376 // Read-modify-write the per-page GBS store through the SSOT /save route (the
377 // same endpoint the importer uses) then render + inject the merged CSS so it
378 // paints live. Returns the merged payload; throws on any REST failure (tagged
379 // with `gbsStep`) so the caller can decide whether to surface or swallow it
380 // and, if surfacing, which step failed.
381 async function persistPageGbs( apiFetch, incoming, postId ) {
382 let existing;
383 try {
384 existing = await apiFetch( { path: GBS_NS + '/save?scope=page&post_id=' + postId } )
385 .then( function ( g ) {
386 return ( g && g.payload && typeof g.payload === 'object' ) ? g.payload : {};
387 } );
388 } catch ( e ) {
389 throw taggedGbsError( 'read', e );
390 }
391 const merged = mergePayload( existing, incoming );
392 try {
393 await apiFetch( {
394 path: GBS_NS + '/save',
395 method: 'POST',
396 data: { scope: 'page', post_id: postId, payload: merged, replace: true },
397 } );
398 } catch ( e ) {
399 throw taggedGbsError( 'write', e );
400 }
401 try {
402 const r = await apiFetch( {
403 path: GBS_NS + '/render',
404 method: 'POST',
405 data: { payload: merged, post_id: postId, scope: 'page' },
406 } );
407 injectCss( 'spectra-gen-custom-css-' + postId + '-inline-css', r && r.css );
408 } catch ( e ) {
409 throw taggedGbsError( 'render', e );
410 }
411 return merged;
412 }
413
414 // ── Deferred section-GBS persistence (persist on SAVE, never before) ─────────
415 // A generate_section insert carries custom `gs-` class BODIES — semantic-token
416 // CSS (var(--primary)/…) the JIT can't compile as utilities, so it can't ride
417 // the block className the way per-block styling now does. We must NOT write it
418 // to the DB immediately: that persists before the user Saves, breaking the
419 // "nothing hits the DB until Save" contract (and orphaning CSS if they discard).
420 // Instead: RENDER + inject it for a live PREVIEW now (a pure compile — NO /save,
421 // no DB write), ACCUMULATE the payload, and FLUSH it to the GBS store only when
422 // the editor completes a real Save. Never Saved → never persisted.
423
424 // Preview-only: compile the payload via the SSOT renderer and inject it into the
425 // canvas. NO /save — this never touches the DB. (The saved-meta render replaces
426 // this element on the next reload.)
427 async function previewSectionGbs( apiFetch, incoming, postId ) {
428 const r = await apiFetch( {
429 path: GBS_NS + '/render',
430 method: 'POST',
431 data: { payload: incoming, post_id: postId, scope: 'page' },
432 } );
433 injectCss( 'zipwp-gbs-pending-section-' + postId, r && r.css );
434 }
435
436 // Remove the pending-section preview <style> for a post — after Save flushes,
437 // the persisted-meta render (spectra-gen-custom-css-<postId>) paints the
438 // section, so the preview element is stale duplicate CSS.
439 function removePendingSectionStyle( postId ) {
440 const el = canvasDoc().getElementById( 'zipwp-gbs-pending-section-' + postId );
441 if ( el && el.parentNode ) {
442 el.parentNode.removeChild( el );
443 }
444 }
445
446 // Build a deferred saver. Deps are injectable (apiFetch, editorStore, subscribe,
447 // persist, preview, merge) so the accumulate → flush-on-save logic is testable
448 // without a live wp.data. `queue(incoming, postId)` merges the payload into the
449 // pending set (per post), paints the preview, and arms a ONE-TIME subscription
450 // that flushes every pending payload through the real persist the first time a
451 // NON-autosave Save completes successfully.
452 function createSectionGbsSaver( deps ) {
453 const apiFetch = deps.apiFetch;
454 const editorStore = deps.editorStore; // () => core/editor select, or the select object
455 const subscribe = deps.subscribe; // (cb) => unsubscribe
456 const persist = deps.persist || persistPageGbs;
457 const preview = deps.preview || previewSectionGbs;
458 const removePreview = deps.removePreview || removePendingSectionStyle;
459 const merge = deps.merge || mergePayload;
460 const pending = {}; // postId -> merged payload
461 let armed = false;
462 let wasSaving = false;
463
464 function resolveStore() {
465 return typeof editorStore === 'function' ? editorStore() : editorStore;
466 }
467 function flush() {
468 Object.keys( pending ).forEach( function ( postId ) {
469 const payload = pending[ postId ];
470 // Optimistically clear so a second Save with nothing new is a no-op…
471 delete pending[ postId ];
472 Promise.resolve( persist( apiFetch, payload, Number( postId ) ) ).then( function () {
473 // …persisted: the saved-meta render now owns the paint, so drop
474 // the stale preview <style> — but ONLY if nothing was re-queued
475 // for this post while the persist was in flight. A queue() during
476 // the flush re-creates the shared per-post preview element for a
477 // section that hasn't persisted yet; removing it here would leave
478 // that section unstyled until the next Save.
479 if ( ! pending[ postId ] ) {
480 removePreview( Number( postId ) );
481 }
482 } ).catch( function ( e ) {
483 // A 'render'-tagged rejection means the /save DB write SUCCEEDED
484 // and only the live paint failed (recoverable by reload). The
485 // payload IS persisted, so treat it like success: drop the stale
486 // preview and do NOT re-queue (re-queuing would rewrite an
487 // already-saved body and, via the merge below, could clobber a
488 // newer queued edit).
489 if ( e && e.gbsStep === 'render' ) {
490 if ( ! pending[ postId ] ) {
491 removePreview( Number( postId ) );
492 }
493 return;
494 }
495 // A genuine not-persisted failure (read/write): re-queue so the
496 // NEXT Save retries — a transient REST error must not permanently
497 // lose the section's styling. `payload` is the OLDER failed batch,
498 // so merge it UNDER anything queued since (2nd arg wins per key)
499 // to keep a newer regenerate from being clobbered by the stale body.
500 pending[ postId ] = merge( payload, pending[ postId ] || {} );
501 // eslint-disable-next-line no-console -- developer signal; retried on the next Save
502 console.warn( '[ZIP AI] deferred section-styles flush failed on Save (will retry next Save)', e );
503 } );
504 } );
505 }
506 function onStoreChange() {
507 const sel = resolveStore();
508 if ( ! sel || typeof sel.isSavingPost !== 'function' ) {
509 return;
510 }
511 // A real (non-autosave) save in flight.
512 const saving = sel.isSavingPost() &&
513 ! ( typeof sel.isAutosavingPost === 'function' && sel.isAutosavingPost() );
514 // Transition saving → finished: flush IF the save succeeded (degrade-open
515 // when the selector is absent — a completed save with no failure signal).
516 if ( wasSaving && ! saving ) {
517 const succeeded = typeof sel.didPostSaveRequestSucceed === 'function'
518 ? sel.didPostSaveRequestSucceed()
519 : true;
520 if ( succeeded ) {
521 flush();
522 }
523 }
524 wasSaving = saving;
525 }
526 function arm() {
527 if ( armed ) {
528 return;
529 }
530 armed = true;
531 if ( typeof subscribe === 'function' ) {
532 subscribe( onStoreChange );
533 }
534 }
535 return {
536 queue ( incoming, postId ) {
537 if ( ! incoming || ! postId ) {
538 return Promise.resolve();
539 }
540 pending[ postId ] = merge( pending[ postId ] || {}, incoming );
541 arm();
542 // Preview the MERGED payload (not just this section) so a second
543 // generate_section doesn't overwrite the single per-post preview
544 // <style> with only its own CSS — earlier sections would go unstyled
545 // until Save otherwise.
546 return Promise.resolve( preview( apiFetch, pending[ postId ], postId ) ).catch( function () {} );
547 },
548 // Test seams — the pending set + the store-change handler.
549 _pending: pending,
550 _onStoreChange: onStoreChange,
551 };
552 }
553
554 // Lazily-built browser singleton (ONE subscription per editor session), wired to
555 // the live wp.data core/editor store. `queueSectionGbsForSave` is what the
556 // apply-change section path calls.
557 let _sectionGbsSaver = null;
558 function sectionGbsSaver( apiFetch ) {
559 if ( ! _sectionGbsSaver ) {
560 _sectionGbsSaver = createSectionGbsSaver( {
561 apiFetch,
562 editorStore: editorSelect,
563 subscribe ( cb ) {
564 return ( typeof window !== 'undefined' && window.wp && window.wp.data && window.wp.data.subscribe )
565 ? window.wp.data.subscribe( cb )
566 : function () {};
567 },
568 } );
569 }
570 return _sectionGbsSaver;
571 }
572 function queueSectionGbsForSave( apiFetch, incoming, postId ) {
573 return sectionGbsSaver( apiFetch ).queue( incoming, postId );
574 }
575
576 if ( typeof window !== 'undefined' ) {
577 window.zipwpEditorShared = window.zipwpEditorShared || {};
578 window.zipwpEditorShared.mergePayload = mergePayload;
579 window.zipwpEditorShared.canvasDoc = canvasDoc;
580 window.zipwpEditorShared.injectCss = injectCss;
581 window.zipwpEditorShared.persistPageGbs = persistPageGbs;
582 window.zipwpEditorShared.previewSectionGbs = previewSectionGbs;
583 window.zipwpEditorShared.createSectionGbsSaver = createSectionGbsSaver;
584 window.zipwpEditorShared.queueSectionGbsForSave = queueSectionGbsForSave;
585 window.zipwpEditorShared.currentPostId = currentPostId;
586 window.zipwpEditorShared.postIdRefusal = postIdRefusal;
587 window.zipwpEditorShared.editorSelect = editorSelect;
588 window.zipwpEditorShared.editorDispatch = editorDispatch;
589 window.zipwpEditorShared.currentMeta = currentMeta;
590 window.zipwpEditorShared.blockEditorSelect = blockEditorSelect;
591 window.zipwpEditorShared.blockEditorDispatch = blockEditorDispatch;
592 window.zipwpEditorShared.rootClientId = rootClientId;
593 window.zipwpEditorShared.isAiEditable = isAiEditable;
594 window.zipwpEditorShared.isAiInsertableRoot = isAiInsertableRoot;
595 window.zipwpEditorShared.bannedVisualAttrs = bannedVisualAttrs;
596 window.zipwpEditorShared.isBannedVisualAttr = isBannedVisualAttr;
597 window.zipwpEditorShared.isJsCapable = isJsCapable;
598 window.zipwpEditorShared.referencedIds = referencedIds;
599 window.zipwpEditorShared.classDependents = classDependents;
600 window.zipwpEditorShared.anchorDependents = anchorDependents;
601 }
602 if ( typeof module !== 'undefined' && module.exports ) {
603 module.exports = {
604 mergePayload,
605 canvasDoc,
606 injectCss,
607 persistPageGbs,
608 previewSectionGbs,
609 createSectionGbsSaver,
610 queueSectionGbsForSave,
611 currentPostId,
612 postIdRefusal,
613 editorSelect,
614 editorDispatch,
615 currentMeta,
616 blockEditorSelect,
617 blockEditorDispatch,
618 isJsCapable,
619 referencedIds,
620 classDependents,
621 anchorDependents,
622 rootClientId,
623 isAiEditable,
624 isAiInsertableRoot,
625 bannedVisualAttrs,
626 isBannedVisualAttr,
627 };
628 }
629 }() );
630