PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.7
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.7
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 / styles / handler.js

handler.js in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.7, at assets/js/tools/editor/styles/handler.js

328 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * editor/get-styles + editor/set-styles — typed read/patch over the GBS CSS
3 * store (the CSS source-of-truth), symmetric with editor/get-context (HTML) and
4 * editor/get-scripts (JS). ONE store key, two scopes:
5 *
6 * scope:'page' → post meta `spectra_blocks_pro_gs_user_css` via the GBS
7 * `/global-styles/save` route (scope:page) — IMMEDIATE write
8 * (the same SSOT endpoint the importer uses), then live paint.
9 * scope:'global' → the same-named WP OPTION (header/footer/site-wide chrome),
10 * read via GET /global-styles/user-css and written via the
11 * shared /global-styles/sitewide merge route — IMMEDIATE +
12 * site-wide (not reversible by discard).
13 *
14 * READ → MERGE only the touched buckets → WRITE (never full-replace, so importer
15 * chrome + user classes survive), then RENDER the merged payload via the SSOT
16 * GenCssRenderer (REST /global-styles/render — no string hacks) and inject it
17 * into the canvas iframe for live paint. Cascade tier: GBS classes sit BELOW
18 * block attributes and ABOVE block defaults — the renderer emits that specificity.
19 *
20 * @package zip-ai
21 */
22 (function () {
23 var META_KEY = 'spectra_blocks_pro_gs_user_css'; // page post-meta AND global option key
24 var NS = '/spectra-blocks/v1/global-styles';
25
26 // Editor-store access (select/dispatch core/editor + session meta) comes from
27 // the ONE shared source (editor/shared/editor-shared-utils.js): window in the
28 // browser, require() under jest — so the editor handlers can't drift.
29 function sharedEditorUtils() {
30 if (typeof window !== 'undefined' && window.zipwpEditorShared) return window.zipwpEditorShared;
31 if (typeof require === 'function') {
32 try { return require('../shared/editor-shared-utils.js'); } catch (e) { return null; }
33 }
34 return null;
35 }
36 function editorSelect() { var u = sharedEditorUtils(); return u && u.editorSelect ? u.editorSelect() : null; }
37 function apiFetch(opts) {
38 if (!(window.wp && window.wp.apiFetch)) {
39 return Promise.reject(new Error('wp.apiFetch unavailable'));
40 }
41 return window.wp.apiFetch(opts);
42 }
43
44 // Merge the touched buckets of `incoming` onto `existing` (read-modify-write).
45 // Object buckets (classes/wrapperStyles/rootStyles/scopeVars/presetLock/
46 // mediaQuery/object-keyframes) merge PER ENTRY — `null` deletes that entry;
47 // a `null` whole bucket deletes the bucket. Array buckets (imports / list
48 // keyframes) REPLACE. Never wholesale-clobbers a bucket the caller didn't send.
49 function mergePayload(existing, incoming) {
50 var out = Object.assign({}, existing || {});
51 out.v = '1';
52 Object.keys(incoming || {}).forEach(function (bucket) {
53 if (bucket === 'v') return;
54 var inc = incoming[bucket];
55 if (inc === null) { delete out[bucket]; return; }
56 if (Array.isArray(inc)) { out[bucket] = inc.slice(); return; }
57 if (typeof inc !== 'object') return;
58 var base = (out[bucket] && typeof out[bucket] === 'object' && !Array.isArray(out[bucket]))
59 ? Object.assign({}, out[bucket])
60 : {};
61 Object.keys(inc).forEach(function (key) {
62 if (inc[key] === null) { delete base[key]; } else { base[key] = inc[key]; }
63 });
64 out[bucket] = base;
65 });
66 return out;
67 }
68
69 // The block-editor canvas runs in an iframe; styles must be injected THERE.
70 function canvasDoc() {
71 var ifr = document.querySelector('iframe[name="editor-canvas"]');
72 return ifr && ifr.contentDocument ? ifr.contentDocument : document;
73 }
74 function injectCss(elementId, css) {
75 var doc = canvasDoc();
76 var el = doc.getElementById(elementId);
77 if (!el) {
78 el = doc.createElement('style');
79 el.id = elementId;
80 (doc.head || doc.documentElement).appendChild(el);
81 }
82 el.textContent = css || '';
83 }
84
85 function bucketsOf(payload) {
86 return Object.keys(payload || {}).filter(function (k) { return k !== 'v'; });
87 }
88
89 // ── STYLE CONTEXT (ownership) ──────────────────────────────────────────────
90 // The OWNERSHIP MODEL: a visual property is set by ONE of three layers, in
91 // descending CSS specificity — a block ATTRIBUTE, a GBS CLASS body, or the
92 // block DEFAULT (DEVELOPER-INSTRUCTIONS §5.1). To change a property you edit
93 // its current OWNER (update the existing class, don't stack a new one; clear a
94 // block attr that pins it). This resolver answers "who owns each property" so
95 // the agent never hand-resolves specificity. It does NOT compute the exact
96 // frontend winner (the editor canvas inverts utility-vs-gsClass specificity,
97 // and utilities are JIT-compiled, not in the GBS payload) — `effective` is the
98 // rendered truth and the agent's verify-iterate loop corrects any mis-guess.
99 // The property map is small and explicit ON PURPOSE: it is this tool's job.
100 // `prop` is the kebab CSS property — it doubles as the GBS-body key (bodies are
101 // kebab, e.g. `font-size`) and the getComputedStyle key. Only `attrs` (the
102 // block-attribute path(s) for that property) is non-derivable.
103 var STYLE_PROPS = [
104 { prop: 'color', attrs: ['style.color.text'] },
105 { prop: 'background-color', attrs: ['style.color.background', 'background.color'] },
106 { prop: 'padding', attrs: ['style.spacing.padding'] },
107 { prop: 'margin', attrs: ['style.spacing.margin'] },
108 { prop: 'font-size', attrs: ['style.typography.fontSize'] },
109 { prop: 'font-weight', attrs: ['style.typography.fontWeight'] },
110 { prop: 'text-align', attrs: ['align'] },
111 { prop: 'max-width', attrs: ['maxWidth'] },
112 ];
113 function deepGet(obj, path) {
114 var cur = obj;
115 var parts = path.split('.');
116 for (var i = 0; i < parts.length; i++) {
117 if (cur === null || typeof cur !== 'object') return undefined;
118 cur = cur[parts[i]];
119 }
120 return cur;
121 }
122 function gsTokensOf(block) {
123 var cn = (block && block.attributes && typeof block.attributes.className === 'string')
124 ? block.attributes.className : '';
125 return cn.trim().split(/\s+/).filter(function (t) { return t && t.indexOf('gs-') === 0; });
126 }
127 function computedForBlock(clientId) {
128 try {
129 var doc = canvasDoc();
130 var el = doc.querySelector('[data-block="' + clientId + '"]');
131 if (!el) return null;
132 return (doc.defaultView || window).getComputedStyle(el);
133 } catch (e) { return null; }
134 }
135 // Per-property { effective, owner, availableSources[] } for ONE block, joining
136 // its gs- class bodies (from the page GBS payload) + its block attributes +
137 // the rendered computed value. owner = the highest-specificity layer that
138 // declares the property (attr > gbs > default) — a best-guess the verify loop
139 // refines; `effective` is always the rendered truth.
140 function buildStyleContext(block, pagePayload) {
141 if (!block) return null;
142 var classes = (pagePayload && pagePayload.classes && typeof pagePayload.classes === 'object')
143 ? pagePayload.classes : {};
144 var gsTokens = gsTokensOf(block);
145 var cs = computedForBlock(block.clientId);
146 var attrs = block.attributes || {};
147 var properties = {};
148 STYLE_PROPS.forEach(function (def) {
149 var sources = [];
150 // block_attribute tier (highest specificity)
151 def.attrs.forEach(function (path) {
152 var v = deepGet(attrs, path);
153 if (v !== undefined && v !== null && v !== '') {
154 sources.push({ type: 'block_attribute', path: path, value: v });
155 }
156 });
157 // gbs class tier — only classes ACTUALLY on this block, only if they declare it
158 gsTokens.forEach(function (token) {
159 var body = classes[token] && classes[token].default;
160 if (!body || typeof body !== 'object') return;
161 var v = body[def.prop];
162 if (v !== undefined && v !== null && v !== '') {
163 sources.push({ type: 'gbs', class: token, value: v });
164 }
165 });
166 var owner = sources.length === 0
167 ? 'default'
168 : (sources[0].type === 'block_attribute'
169 ? 'block_attribute:' + sources[0].path
170 : 'gbs:' + sources[0].class);
171 properties[def.prop] = {
172 effective: cs ? cs.getPropertyValue(def.prop) : null,
173 owner: owner,
174 availableSources: sources,
175 };
176 });
177 return { client_id: block.clientId, gbs_classes: gsTokens, properties: properties };
178 }
179
180 // ── get-styles ───────────────────────────────────────────────────────────
181 async function handleGetStyles(args) {
182 var scope = args && args.scope === 'global' ? 'global' : 'page';
183 if (scope === 'page') {
184 var sel = editorSelect();
185 var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
186 if (!postId) {
187 return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
188 }
189 try {
190 var pres = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
191 var pp = (pres && pres.payload && typeof pres.payload === 'object') ? pres.payload : {};
192 var out = { scope: 'page', post_id: postId, buckets: bucketsOf(pp), payload: pp };
193 // STYLE CONTEXT (ownership) for a target block — the agent reads
194 // this BEFORE a styling edit so it updates the existing owner
195 // instead of guessing/stacking. Best-effort: a missing block /
196 // unmounted node simply omits styleContext (never blocks the read).
197 var clientId = args && typeof args.client_id === 'string' ? args.client_id : null;
198 if (clientId && sel && sel.getBlock) {
199 var blk = sel.getBlock(clientId);
200 var ctx = buildStyleContext(blk, pp);
201 if (ctx) out.styleContext = ctx;
202 }
203 return { success: true, data: out };
204 } catch (e) {
205 return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
206 }
207 }
208 try {
209 var res = await apiFetch({ path: NS + '/user-css' });
210 var gp = (res && res.payload && typeof res.payload === 'object') ? res.payload : {};
211 return { success: true, data: { scope: 'global', buckets: bucketsOf(gp), payload: gp } };
212 } catch (e) {
213 return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
214 }
215 }
216
217 // ── set-styles ───────────────────────────────────────────────────────────
218 async function handleSetStyles(args) {
219 var scope = args && (args.scope === 'page' || args.scope === 'global') ? args.scope : null;
220 var incoming = args && args.payload;
221 if (scope === null) {
222 return { success: false, error: 'invalid_input: scope must be "page" or "global"' };
223 }
224 if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) {
225 return { success: false, error: 'invalid_input: payload must be a schema-v1 object of style buckets' };
226 }
227 if (!bucketsOf(incoming).length) {
228 return { success: false, error: 'invalid_input: payload has no style buckets (classes / wrapperStyles / rootStyles / …)' };
229 }
230
231 if (scope === 'page') {
232 var sel = editorSelect();
233 var postId = sel && sel.getCurrentPostId ? sel.getCurrentPostId() : 0;
234 if (!postId) {
235 return { success: false, error: 'editor_unavailable: no current post id (is the block editor open?)' };
236 }
237
238 // Read-modify-write the per-page GBS store through the SSOT /save route
239 // (the same endpoint the importer uses) — IMMEDIATE, no editPost session.
240 var existing;
241 try {
242 var g = await apiFetch({ path: NS + '/save?scope=page&post_id=' + postId });
243 existing = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
244 } catch (e) {
245 return { success: false, error: 'page_read_failed: ' + String(e && e.message ? e.message : e) };
246 }
247 var merged = mergePayload(existing, incoming);
248 try {
249 await apiFetch({ path: NS + '/save', method: 'POST', data: { scope: 'page', post_id: postId, payload: merged, replace: true } });
250 } catch (e) {
251 return { success: false, error: 'page_write_failed: ' + String(e && e.message ? e.message : e) };
252 }
253
254 // Live paint: render the merged payload (SSOT) and replace the page CSS element.
255 try {
256 var r = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: merged, post_id: postId, scope: 'page' } });
257 injectCss('spectra-gen-custom-css-' + postId + '-inline-css', r && r.css);
258 } catch (e) {
259 return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
260 }
261 return {
262 success: true,
263 data: {
264 scope: 'page',
265 post_id: postId,
266 buckets: bucketsOf(incoming),
267 note: 'Written immediately via the GBS /save route; painted live.',
268 },
269 };
270 }
271
272 // scope === 'global' — read-modify-write the option (immediate, site-wide).
273 var existingGlobal;
274 try {
275 var g = await apiFetch({ path: NS + '/user-css' });
276 existingGlobal = (g && g.payload && typeof g.payload === 'object') ? g.payload : {};
277 } catch (e) {
278 return { success: false, error: 'global_read_failed: ' + String(e && e.message ? e.message : e) };
279 }
280 var mergedGlobal = mergePayload(existingGlobal, incoming);
281 try {
282 // /sitewide replaces non-class buckets wholesale → send the FULL merged
283 // payload so the write equals the merged state (chrome/user classes kept).
284 await apiFetch({ path: NS + '/sitewide', method: 'POST', data: { payload: mergedGlobal } });
285 } catch (e) {
286 return { success: false, error: 'global_write_failed: ' + String(e && e.message ? e.message : e) };
287 }
288 try {
289 var rg = await apiFetch({ path: NS + '/render', method: 'POST', data: { payload: mergedGlobal, post_id: 0, scope: 'global' } });
290 // Append-last override so the live global paint wins source-order ties
291 // (deletions converge on reload, consistent with the live-JIT model).
292 injectCss('zipwp-gbs-live-global', rg && rg.css);
293 } catch (e) {
294 return { success: false, error: 'render_failed: ' + String(e && e.message ? e.message : e) };
295 }
296 return {
297 success: true,
298 data: {
299 scope: 'global',
300 buckets: bucketsOf(incoming),
301 note: 'Site-wide + IMMEDIATE: applied to every page now (not reversible by discarding the editor).',
302 },
303 };
304 }
305
306 function initHandler() {
307 if (window.zipwpMcp && window.zipwpMcp.registerTool) {
308 window.zipwpMcp.registerTool('editor/get-styles', async function (args) { return handleGetStyles(args); }, { previewMode: 'client' });
309 window.zipwpMcp.registerTool('editor/set-styles', async function (args) { return handleSetStyles(args); }, { previewMode: 'client' });
310 } else {
311 setTimeout(initHandler, 100);
312 }
313 }
314 initHandler();
315
316 // Test-only surface (Node/CommonJS) — inert in the browser bundle.
317 if (typeof module !== 'undefined' && module.exports) {
318 module.exports = {
319 handleGetStyles: handleGetStyles,
320 handleSetStyles: handleSetStyles,
321 mergePayload: mergePayload,
322 // Ownership resolver (per-property effective/owner/availableSources).
323 // Exported PURE so a unit test locks the join, not a reimplementation.
324 buildStyleContext: buildStyleContext,
325 };
326 }
327 })();
328