PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.8
ZIP AI – AI Website Builder & AI Agent v0.0.8
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 / editor / quickedit.js

quickedit.js in ZIP AI – AI Website Builder & AI Agent 0.0.8, at assets/js/editor/quickedit.js

1,208 lines 72.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * ZIP AI Quick Edit — block-toolbar popover for fast, one-shot inline edits.
3 *
4 * SEPARATE from the chat agent loop: streams from /api/agent/inline-edit/*
5 * (brain runs a single LLM call, no runTurn) and applies straight to the
6 * block via setAttributes (native Gutenberg undo). Block-aware: each block
7 * type exposes only the ops that fit it. UI mirrors the ZIP AI design spec.
8 *
9 * Self-contained IIFE — self-registers via addFilter('editor.BlockEdit', …)
10 * and a load-time style inject; nothing calls into it. Loaded alongside
11 * editor-plugin.js (prod: concatenated by Gruntfile's editor/**\/*.js glob;
12 * dev: enqueued explicitly in react-manager.php). The per-block "Ask ZIP AI"
13 * button is the single block-toolbar ZIP AI entry point; chat stays reachable
14 * via the pinned ZIP AI Assistant sidebar button (editor-plugin.js).
15 */
16
17 ( function() {
18 'use strict';
19
20 const { createElement: el, useEffect, useState, useRef, useCallback, useMemo } = wp.element;
21 const { __, sprintf } = wp.i18n;
22 const { createHigherOrderComponent } = wp.compose;
23 const { addFilter } = wp.hooks;
24 const BlockControls = wp.blockEditor?.BlockControls || wp.editor?.BlockControls;
25 const { ToolbarGroup, ToolbarButton, Dropdown } = wp.components;
26
27 // ZIP AI app icon — white sparkle on orange tile.
28 // Matches src/components/shared/ZipBrand.jsx (ZIP_AI_ICON) / zip-ai-icon.svg.
29 // Local copy so this file has no runtime dependency on editor-plugin.js.
30 const ZIP_GLYPH = 'M10.6163 9.04474C14.6886 8.38907 21.5161 8.52189 22.6036 8.55646C22.6688 8.55139 22.7246 8.63189 22.6797 8.68634C22.4473 9.07806 21.6058 10.2989 19.3233 12.0047C19.2606 12.0435 19.2876 12.1771 19.3692 12.1707C20.1605 12.2111 22.8025 12.243 26.9971 11.7996C27.0778 11.7942 27.1306 11.857 27.1036 11.9266C26.8268 12.5935 25.3999 15.3714 20.4708 17.6902C20.3757 17.7318 20.4041 17.8824 20.502 17.8748L24.6436 17.8934C24.7262 17.9042 24.7826 18.0008 24.7217 18.0565C23.818 19.1117 18.3426 24.972 10.2803 23.118C7.10173 22.3795 4.9017 19.4422 4.88873 15.8934C4.87595 12.3444 7.01332 9.61306 10.6163 9.04474ZM12.6895 13.7557C12.612 13.5437 12.312 13.5437 12.2344 13.7557L12.003 14.3885C11.8213 14.8846 11.5337 15.3352 11.1602 15.7088C10.7865 16.0825 10.3352 16.3699 9.83892 16.5516L9.20611 16.784C8.99451 16.8617 8.99436 17.1615 9.20611 17.2391L9.83892 17.4705C10.3352 17.6522 10.7865 17.9396 11.1602 18.3133C11.5339 18.687 11.8213 19.1383 12.003 19.6346L12.2344 20.2664C12.312 20.4787 12.612 20.4787 12.6895 20.2664L12.921 19.6346C13.1026 19.1383 13.391 18.687 13.7647 18.3133C14.1383 17.9397 14.5889 17.6522 15.085 17.4705L15.7178 17.2391C15.9301 17.1615 15.93 16.8616 15.7178 16.784L15.085 16.5516C14.5889 16.3699 14.1383 16.0824 13.7647 15.7088C13.391 15.3351 13.1026 14.8847 12.921 14.3885L12.6895 13.7557ZM15.792 11.8924C15.7533 11.7827 15.6033 11.7827 15.5645 11.8924L15.4483 12.2195C15.3575 12.4763 15.2142 12.7098 15.0274 12.9031C14.8405 13.0965 14.6144 13.2456 14.3663 13.3397L14.0499 13.4588C13.9442 13.499 13.9442 13.6549 14.0499 13.6951L14.3663 13.8143C14.6144 13.9083 14.8405 14.0574 15.0274 14.2508C15.2142 14.4441 15.3575 14.6776 15.4483 14.9344L15.5645 15.2615C15.6033 15.3714 15.7533 15.3714 15.792 15.2615L15.9073 14.9344C15.9981 14.6776 16.1423 14.4442 16.3292 14.2508C16.516 14.0575 16.7412 13.9083 16.9893 13.8143L17.3057 13.6951C17.4119 13.655 17.4119 13.4989 17.3057 13.4588L16.9893 13.3397C16.7413 13.2456 16.516 13.0965 16.3292 12.9031C16.1423 12.7098 15.9981 12.4763 15.9073 12.2195L15.792 11.8924ZM26.9971 11.7996H26.9952L27.0118 11.7986C27.0069 11.7992 27.002 11.7991 26.9971 11.7996Z';
31 const ChatBubbleIcon = () => el( 'svg', {
32 width: 24,
33 height: 24,
34 viewBox: '0 0 32 32',
35 fill: 'none',
36 'aria-hidden': 'true',
37 focusable: 'false',
38 },
39 el( 'rect', { width: 32, height: 32, rx: 8.88889, fill: '#FF580E' } ),
40 el( 'path', { d: ZIP_GLYPH, fill: '#FFFFFF' } )
41 );
42
43 const ic = ( children, vb ) => el( 'svg', {
44 width: 16, height: 16, viewBox: vb || '0 0 24 24', fill: 'none',
45 stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
46 'aria-hidden': 'true',
47 }, children );
48
49 // ZIP AI brand mark — the quick-edit button is "Ask ZIP AI", not a generic sparkle.
50 const EraMark = () => ChatBubbleIcon();
51
52 // Animated loader — the "Snake border" variant from the ZIP AI Loaders spec: a
53 // static ink mark with a comet segment gliding around its border (conic
54 // gradient swept via the animated --eqe-a angle) over a faint base track.
55 // Replaces the generic wp Spinner while a quick edit streams. Honors
56 // prefers-reduced-motion (CSS).
57 const EraLoader = () => el( 'span', { className: 'eqe-loader' },
58 el( 'span', { className: 'eqe-snake-track', 'aria-hidden': 'true' } ),
59 el( 'span', { className: 'eqe-snake-ring', 'aria-hidden': 'true' } ),
60 el( 'svg', { className: 'eqe-snake-glyph', width: 14, height: 14, viewBox: '0 0 32 32', fill: 'none', 'aria-hidden': 'true' },
61 el( 'path', { d: ZIP_GLYPH, fill: '#FFFFFF' } )
62 )
63 );
64
65 // Ink spinner ring — replaces the generic blue wp Spinner so every loading
66 // state stays in the ZIP AI ink palette. `on-dark` variant for dark (ink-fill)
67 // buttons where a light ring reads better.
68 const spinner = ( mod ) => el( 'span', { className: 'eqe-spin' + ( mod ? ' ' + mod : '' ), 'aria-hidden': 'true' } );
69
70 // Lucide icon path builder (MIT). p(d) → keyed <path>.
71 const p = ( d, k ) => el( 'path', { key: k || d.slice( 0, 6 ), d } );
72
73 // Per-intent menu icon (Lucide) + label.
74 const INTENT_META = {
75 rewrite: { label: 'Rewrite', icon: () => ic( [ p( 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8', 'a' ), p( 'M3 3v5h5', 'b' ) ] ) }, // rotate-ccw
76 improve: { label: 'Improve', icon: () => ic( [ p( 'm12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z', 'a' ), p( 'M5 3v4', 'b' ), p( 'M19 17v4', 'c' ), p( 'M3 5h4', 'd' ), p( 'M17 19h4', 'e' ) ] ) }, // sparkles
77 shorten: { label: 'Shorten', icon: () => ic( [ p( 'M21 6H3', 'a' ), p( 'M15 12H3', 'b' ), p( 'M17 18H3', 'c' ) ] ) }, // align-left
78 expand: { label: 'Expand', icon: () => ic( [ p( 'M3 6h18', 'a' ), p( 'M3 12h18', 'b' ), p( 'M3 18h18', 'c' ) ] ) }, // align-justify
79 punchier: { label: 'Make punchier', icon: () => ic( p( 'M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z' ) ) }, // zap
80 simplify: { label: 'Simplify', icon: () => ic( [ p( 'm21.64 3.64-1.28-1.28a1.2 1.2 0 0 0-1.72 0L2.36 18.64a1.2 1.2 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72', 'a' ), p( 'm14 7 3 3', 'b' ), p( 'M5 6v4', 'c' ), p( 'M7 8H3', 'd' ) ] ) }, // wand-sparkles
81 professional: { label: 'Professional', icon: () => ic( [ p( 'M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16', 'a' ), el( 'rect', { key: 'r', width: 20, height: 14, x: 2, y: 6, rx: 2 } ) ] ) }, // briefcase
82 friendly: { label: 'Friendly', icon: () => ic( [ el( 'circle', { key: 'c', cx: 12, cy: 12, r: 10 } ), p( 'M8 14s1.5 2 4 2 4-2 4-2', 'm' ), p( 'M9 9h.01', 'l' ), p( 'M15 9h.01', 'r' ) ] ) }, // smile
83 grammar: { label: 'Grammar', icon: () => ic( p( 'M20 6 9 17l-5-5' ) ) }, // check
84 humanize: { label: 'Humanize', icon: () => ic( [ p( 'M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2', 'a' ), el( 'circle', { key: 'c', cx: 12, cy: 7, r: 4 } ) ] ) }, // user
85 };
86
87 // Common edits shown up front; the rest fold behind "+ More tones & styles".
88 // Both filter against caps.text.ops per block type.
89 const PRIMARY_INTENTS = [ 'rewrite', 'improve', 'shorten', 'expand', 'grammar' ];
90
91 const MORE_INTENTS = [ 'simplify', 'professional', 'friendly', 'humanize', 'punchier' ];
92 const TEXT_FULL = [ 'rewrite', 'improve', 'shorten', 'expand', 'punchier', 'simplify', 'professional', 'friendly', 'grammar', 'humanize' ];
93
94 // Last-resort overrides: ONLY blocks whose editable text is a plain string
95 // attribute that ISN'T named `text` and has no `source` — so neither
96 // auto-rule in textFieldsFor() can find it. Everything else is discovered:
97 // • source: html/rich-text/text attrs → core/*, uagb/*, Kadence, most builders
98 // • a plain string attr named `text` → Spectra's convention (content,
99 // button, all the loop/pagination buttons, countdown-label, form-button…)
100 // Map value = list of extra field names on that block.
101 const TEXT_MAP = {
102 'spectra/counter': [ 'prefix' ],
103 'spectra-pro/mega-menu': [ 'label', 'description', 'title' ],
104 'uagb/buttons-child': [ 'label' ],
105 };
106 // Image-block apply overrides: blocks that store the image in PLAIN attrs
107 // (CSS background / custom save, no <img> source) so it can't be auto-found.
108 // { url, id, alt } = the attribute names the apply writes into.
109 const IMAGE_MAP = {
110 'core/cover': { url: 'url', id: 'id', alt: 'alt' },
111 'uagb/image': { url: 'url', id: 'id', alt: 'alt' },
112 };
113 const IMG_DESC_CACHE = {};
114 // Describe how to set an image on a block: { url, id, alt } attribute names,
115 // or null if it isn't an image block. Auto-detects any block that renders an
116 // <img> (an attr with source:attribute, attribute:'src', selector incl. img)
117 // — which safely EXCLUDES video/audio/embed (their src selector isn't img) —
118 // then falls back to IMAGE_MAP for plain-attr blocks (cover, uagb/image).
119 function imageDescriptorFor( name ) {
120 if ( name in IMG_DESC_CACHE ) {
121 return IMG_DESC_CACHE[ name ];
122 }
123 let desc = IMAGE_MAP[ name ] || null;
124 const type = wp.blocks && wp.blocks.getBlockType ? wp.blocks.getBlockType( name ) : null;
125 if ( ! desc && type && type.attributes ) {
126 const attrs = type.attributes;
127 const imgSel = ( d ) => d && d.source === 'attribute' && typeof d.selector === 'string' && /\bimg\b/.test( d.selector );
128 let urlAttr = null,
129 altAttr = null;
130 Object.keys( attrs ).forEach( ( k ) => {
131 if ( imgSel( attrs[ k ] ) && attrs[ k ].attribute === 'src' && ! urlAttr ) {
132 urlAttr = k;
133 }
134 if ( imgSel( attrs[ k ] ) && attrs[ k ].attribute === 'alt' && ! altAttr ) {
135 altAttr = k;
136 }
137 } );
138 if ( urlAttr ) {
139 const numId = ( k ) => attrs[ k ] && attrs[ k ].type === 'number';
140 const idAttr = numId( 'id' ) ? 'id' : numId( 'mediaId' ) ? 'mediaId'
141 : Object.keys( attrs ).find( ( k ) => numId( k ) && /id$/i.test( k ) ) || 'id';
142 desc = { url: urlAttr, id: idAttr, alt: altAttr || 'alt' };
143 }
144 }
145 if ( type ) {
146 IMG_DESC_CACHE[ name ] = desc;
147 } // cache once the type has registered
148 return desc;
149 }
150
151 const RATIOS = [
152 { id: '1024x1024', label: '1:1', w: 20, h: 20 },
153 { id: '1536x1024', label: '16:9', w: 26, h: 16 },
154 { id: '1024x1536', label: '4:5', w: 16, h: 20 },
155 ];
156 // Camera mark — the image-generation action reads as "photo", not a sparkle.
157 const cameraIcon = () => ic( [
158 p( 'M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z', 'a' ),
159 el( 'circle', { key: 'c', cx: 12, cy: 13, r: 3 } ),
160 ] );
161 const searchIcon = () => ic( el( 'path', { d: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3' } ) );
162 const checkIcon = () => ic( el( 'path', { d: 'M20 6L9 17l-5-5' } ) );
163
164 // Style presets — gradient-thumbnail cards (per the ZIP AI image-tool spec).
165 const STYLE_PRESETS = [
166 { id: 'Photographic', label: 'Photographic', grad: 'linear-gradient(135deg,#8FAE91,#5F726B)', icon: () => ic( [
167 el( 'circle', { key: 'c', cx: 12, cy: 12, r: 10 } ),
168 p( 'M14.31 8 20.05 17.94', 'a' ), p( 'M9.69 8 21.17 8', 'b' ), p( 'M7.38 12 13.12 2.06', 'c' ),
169 p( 'M9.69 16 3.95 6.06', 'd' ), p( 'M14.31 16 2.83 16', 'e' ), p( 'M16.62 12 10.88 21.94', 'f' ) ] ) },
170 { id: 'Illustration', label: 'Illustration', grad: 'linear-gradient(135deg,#E7C98C,#CFA257)', icon: () => ic( [
171 p( 'M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.3a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z', 'a' ),
172 el( 'circle', { key: 'c1', cx: 13.5, cy: 6.5, r: 1, fill: 'currentColor', stroke: 'none' } ),
173 el( 'circle', { key: 'c2', cx: 17.5, cy: 10.5, r: 1, fill: 'currentColor', stroke: 'none' } ),
174 el( 'circle', { key: 'c3', cx: 6.5, cy: 12.5, r: 1, fill: 'currentColor', stroke: 'none' } ),
175 el( 'circle', { key: 'c4', cx: 8.5, cy: 7.5, r: 1, fill: 'currentColor', stroke: 'none' } ) ] ) },
176 { id: '3D render', label: '3D render', grad: 'linear-gradient(135deg,#B6A6E6,#8E72D2)', icon: () => ic( [
177 p( 'M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z', 'a' ),
178 p( 'm3.3 7 8.7 5 8.7-5', 'b' ), p( 'M12 22V12', 'c' ) ] ) },
179 { id: 'Minimal', label: 'Minimal', grad: 'linear-gradient(135deg,#E6E7EA,#C2C5CA)', icon: () => ic( el( 'circle', { cx: 12, cy: 12, r: 9 } ) ) },
180 { id: 'Vibrant', label: 'Vibrant', grad: 'linear-gradient(135deg,#F09B91,#DD5F77)', icon: () => ic( [
181 el( 'circle', { key: 'c', cx: 12, cy: 12, r: 4 } ),
182 p( 'M12 2v2', 'a' ), p( 'M12 20v2', 'b' ), p( 'm4.93 4.93 1.41 1.41', 'c' ), p( 'm17.66 17.66 1.41 1.41', 'd' ),
183 p( 'M2 12h2', 'e' ), p( 'M20 12h2', 'f' ), p( 'm6.34 17.66-1.41 1.41', 'g' ), p( 'm19.07 4.93-1.41 1.41', 'h' ) ] ) },
184 ];
185
186 // Editable text fields for a block type, discovered generically. Three rules,
187 // widest-first, so almost no block needs a hardcoded entry:
188 // 1. attributes with source: html | rich-text | text → RichText prose
189 // (core/*, legacy uagb/* like info-box, Kadence, most third-party).
190 // 2. a plain string attr literally named `text` → Spectra's editable-content
191 // convention (content, button, loop/pagination buttons, form-button…).
192 // 3. TEXT_MAP → the few odd-named plain attrs rules 1–2 can't see.
193 // Returns [{ field }], deduped. Memoized by block name.
194 const TEXT_FIELDS_CACHE = {}; // by block name — schemas are static at runtime
195 function textFieldsFor( name ) {
196 if ( TEXT_FIELDS_CACHE[ name ] ) {
197 return TEXT_FIELDS_CACHE[ name ];
198 }
199 const type = wp.blocks && wp.blocks.getBlockType ? wp.blocks.getBlockType( name ) : null;
200 const seen = {};
201 const fields = [];
202 const add = ( f ) => {
203 if ( f && ! seen[ f ] ) {
204 seen[ f ] = true; fields.push( f );
205 }
206 };
207 if ( type && type.attributes ) {
208 const attrs = type.attributes;
209 Object.keys( attrs ).forEach( ( key ) => {
210 const def = attrs[ key ];
211 if ( ! def ) {
212 return;
213 }
214 const src = def.source;
215 // (1) sourced RichText — core/*, uagb/*, Kadence, most builders.
216 if ( src === 'html' || src === 'rich-text' || src === 'text' ) {
217 add( key );
218 } else if ( key === 'text' && ! src && def.type === 'string' ) {
219 // (2) a plain string attr literally named `text` — Spectra's
220 // editable-content convention (no source). isProse() guards it.
221 add( key );
222 }
223 } );
224 }
225 ( TEXT_MAP[ name ] || [] ).forEach( add ); // (3) odd-named plain-attr extras
226 const out = fields.map( ( f ) => ( { field: f } ) );
227 if ( type ) {
228 TEXT_FIELDS_CACHE[ name ] = out;
229 } // don't cache before the type registers
230 return out;
231 }
232 // Guard against editing non-prose source:html attrs (icon SVG, raw markup):
233 // keep only values that have visible word characters after stripping tags.
234 function isProse( v ) {
235 if ( typeof v !== 'string' ) {
236 return false;
237 }
238 const plain = v.replace( /<[^>]*>/g, '' ).trim();
239 return plain !== '' && /[\p{L}\p{N}]/u.test( plain );
240 }
241
242 // Visual state on a block's OWN editor node (Gutenberg renders
243 // `[data-block="<clientId>"]`; no wrapper around BlockEdit — that breaks
244 // blocks). 'pending' — shimmer · 'active' — glow ring + scrolled into view ·
245 // 'done' — brief success flash · null — clear. Best-effort.
246 // The Gutenberg canvas may render inside a same-origin iframe
247 // (name="editor-canvas"); blocks live in ITS document, so a main-document
248 // query misses them — which is why scroll-into-view never reached the block.
249 // Look in the canvas iframe first, fall back to the main document (classic).
250 function findBlockEl( clientId ) {
251 const sel = '[data-block="' + clientId + '"]';
252 const frame = document.querySelector( 'iframe[name="editor-canvas"]' );
253 const doc = frame && frame.contentDocument;
254 return ( doc && doc.querySelector( sel ) ) || document.querySelector( sel );
255 }
256 function setBlockState( clientId, state ) {
257 try {
258 const n = findBlockEl( clientId );
259 if ( ! n ) {
260 return;
261 }
262 n.classList.toggle( 'eqe-skeleton-on', state === 'pending' );
263 n.classList.toggle( 'eqe-block-active', state === 'active' );
264 if ( state === 'active' ) {
265 n.scrollIntoView( { behavior: 'smooth', block: 'center' } );
266 }
267 if ( state === 'done' ) {
268 n.classList.add( 'eqe-block-done' ); window.setTimeout( function () {
269 n.classList.remove( 'eqe-block-done' );
270 }, 900 );
271 }
272 if ( state === null ) {
273 n.classList.remove( 'eqe-skeleton-on', 'eqe-block-active', 'eqe-block-done' );
274 }
275 } catch ( e ) {}
276 }
277
278 // A "section" = any block with editable text descendants (detected via the
279 // store, no hardcoded container list — so custom containers work too). We
280 // show "Ask ZIP AI" on it and fan the chosen op out to each descendant text
281 // block. A big section is split into batches (see partitionTargets) and
282 // edited over sequential calls, so even a text-heavy container works.
283 // MAX_SECTION_TARGETS caps the WHOLE section (runaway guard — a whole-page
284 // selection stays per-block); MAX_BATCH_TARGETS + SECTION_COST_CEILING bound
285 // each single call.
286 const MAX_SECTION_TARGETS = 80; // total fan-out cap across all batches
287 const MAX_BATCH_TARGETS = 40; // count cap per single call
288 // Per-batch est-token ceiling. MUST equal the brain's EST_CEILING
289 // (workers/brain/src/http/routes/inlineEdit.ts) — same value + same formula
290 // means a batch the client builds is exactly one the server accepts (no drift).
291 const SECTION_COST_CEILING = 24000;
292 const BATCH_GAP_MS = 300; // pause between sequential batches to ease provider rate limits
293 // Bin-pack targets (in document order) into batches that each fit ONE call:
294 // ≤ SECTION_COST_CEILING est tokens AND ≤ MAX_BATCH_TARGETS blocks. The first
295 // target is admitted unconditionally, so callers must pre-drop any single
296 // target that alone exceeds the ceiling (run() filters them) — then every
297 // target is guaranteed to fit its batch.
298 // Estimate mirrors the brain's estOutputTokens (chars + block count).
299 function partitionTargets( targets ) {
300 const batches = [];
301 let cur = [],
302 curChars = 0;
303 targets.forEach( ( t ) => {
304 const chars = curChars + t.text.length;
305 const count = cur.length + 1;
306 const est = Math.ceil( chars / 2 ) + count * 64 + 4096;
307 if ( cur.length && ( est > SECTION_COST_CEILING || count > MAX_BATCH_TARGETS ) ) {
308 batches.push( cur ); cur = []; curChars = 0;
309 }
310 cur.push( t ); curChars += t.text.length;
311 } );
312 if ( cur.length ) {
313 batches.push( cur );
314 }
315 return batches;
316 }
317 // Collect editable text descendants of a container (fresh from the store):
318 // [{ id, name, field, text }] for every non-empty text block under clientId.
319 // Every editable text target in a block's scope: the block's OWN text fields
320 // PLUS those of every descendant — so a leaf (1 field), a multi-field block
321 // (uagb/info-box → prefix + title + desc), and a container (all descendants)
322 // are one uniform list. `key` = clientId + field: a block can contribute
323 // several fields, but the wire keys edits by id, so each field rides its own
324 // composite id (the brain treats it as opaque and echoes it back).
325 function collectTargets( clientId ) {
326 const be = wp.data.select( 'core/block-editor' );
327 if ( ! be || ! clientId ) {
328 return [];
329 }
330 const out = [];
331 const visit = ( id ) => {
332 const name = be.getBlockName( id );
333 const attrs = be.getBlockAttributes( id ) || {};
334 textFieldsFor( name ).forEach( ( f ) => {
335 const raw = attrs[ f.field ];
336 // WP 6.5+ rich-text source attrs are RichTextData objects, not
337 // strings — coerce here so the whole pipeline stays string-typed
338 // (wire payload, unchanged-diff, revert).
339 const v = ( raw && typeof raw === 'object' && typeof raw.toHTMLString === 'function' )
340 ? raw.toHTMLString() : raw;
341 if ( typeof v === 'string' && isProse( v ) ) {
342 out.push( { id, key: id + '::' + f.field, name, field: f.field, text: v } );
343 }
344 } );
345 };
346 visit( clientId );
347 if ( typeof be.getClientIdsOfDescendants === 'function' ) {
348 be.getClientIdsOfDescendants( [ clientId ] ).forEach( visit );
349 }
350 return out;
351 }
352
353 // The current page's section headings, gathered with the SAME buildPageOutline
354 // the vibe-editing turn uses (core/wp-bridge-host.js) so an inline edit stays
355 // on-topic with the rest of the page — the brain folds the headings into the
356 // prompt as reference-only context. buildPageOutline is pure (reads blocks,
357 // mutates no bridge state); getEditorContext() is NOT — it mints a snapshot id
358 // + bumps the selection revision, so it must never be called from here.
359 // Returns null when the bridge is absent or the page has no headings; the edit
360 // then runs without page context (the brain treats it as optional — no failure).
361 function collectPageOutline() {
362 try {
363 const bridge = window.zipwpMcpBridge;
364 if ( ! bridge || typeof bridge.buildPageOutline !== 'function' ) {
365 return null;
366 }
367 const be = wp.data.select( 'core/block-editor' );
368 const blocks = be && typeof be.getBlocks === 'function' ? be.getBlocks() : null;
369 if ( ! Array.isArray( blocks ) || ! blocks.length ) {
370 return null;
371 }
372 // The brain reads ONLY heading_excerpt, so ship just that — dropping the
373 // seven structural fields buildPageOutline also emits (client_id,
374 // block_name, flags…) that the inline-edit prompt would ignore — and
375 // only rows that actually carry a heading.
376 const rows = ( bridge.buildPageOutline( blocks ) || [] )
377 .map( function ( r ) {
378 return r && typeof r.heading_excerpt === 'string' && r.heading_excerpt
379 ? { heading_excerpt: r.heading_excerpt }
380 : null;
381 } )
382 .filter( Boolean );
383 return rows.length ? rows : null;
384 } catch ( e ) {
385 return null;
386 }
387 }
388
389 function apiBase() {
390 const cfg = window.ZIPAI_CONFIG || {};
391 return { url: ( cfg.apiUrl || '/api' ).replace( /\/$/, '' ), token: cfg.token || '' };
392 }
393
394 // Stream an inline edit (one structured call, SSE) over /inline-edit/stream.
395 // Sends blocks:[{id,block_type,text}] — a single block is a list of one. The
396 // brain emits `edit {id,text}` events then `complete`; onEdit(id,text) fires
397 // per event. Throws on error.
398 //
399 // Goes DIRECT to the brain (verifies the same Sanctum token and bills via
400 // Laravel). The Laravel relay fallback was removed — brainUrl is required.
401 async function streamEdit( payload, onEdit, signal ) {
402 const { token } = apiBase();
403 const base = ( window.ZIPAI_CONFIG || {} ).brainUrl;
404 if ( ! base ) {
405 throw new Error( 'Inline edit unavailable. The import server URL is not configured.' );
406 }
407 const endpoint = base.replace( /\/$/, '' ) + '/inline-edit/stream';
408 const res = await fetch( endpoint, {
409 method: 'POST',
410 headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', Authorization: 'Bearer ' + token },
411 body: JSON.stringify( payload ),
412 signal,
413 } );
414 if ( ! res.ok || ! res.body ) {
415 // Read the curated `user_message` off the body before throwing. This
416 // used to discard the body outright, so the one reject the user can
417 // actually act on — a section too big to rewrite in one pass, whose
418 // message says to edit its blocks individually — surfaced as the bare
419 // "Inline edit failed (400)" fallback. `errText` prefers userMessage.
420 let body = {};
421 try {
422 body = await res.json();
423 } catch ( e ) { /* non-JSON (proxy/HTML error page) — fall through to the status line */ }
424 const err = new Error( 'Inline edit failed (' + res.status + ')' );
425 if ( typeof body.user_message === 'string' && body.user_message ) {
426 err.userMessage = body.user_message;
427 }
428 throw err;
429 }
430 const reader = res.body.getReader();
431 const decoder = new TextDecoder();
432 let buffer = '';
433 let errored = null;
434 for ( ;; ) {
435 const { done, value } = await reader.read();
436 if ( done ) {
437 break;
438 }
439 buffer += decoder.decode( value, { stream: true } );
440 let idx;
441 while ( ( idx = buffer.indexOf( '\n\n' ) ) !== -1 ) {
442 const blk = buffer.slice( 0, idx );
443 buffer = buffer.slice( idx + 2 );
444 let event = null;
445 let data = null;
446 blk.split( '\n' ).forEach( ( line ) => {
447 if ( line.indexOf( 'event:' ) === 0 ) {
448 event = line.slice( 6 ).trim();
449 } else if ( line.indexOf( 'data:' ) === 0 ) {
450 data = line.slice( 5 ).trim();
451 }
452 } );
453 if ( ! event || data === null ) {
454 continue;
455 }
456 let parsed = {};
457 try {
458 parsed = JSON.parse( data );
459 } catch ( e ) {
460 parsed = {};
461 }
462 if ( event === 'edit' && typeof parsed.id === 'string' && typeof parsed.text === 'string' ) {
463 onEdit( parsed.id, parsed.text );
464 } else if ( event === 'error' ) {
465 errored = parsed.error || 'Inline edit failed';
466 }
467 }
468 }
469 if ( errored ) {
470 throw new Error( errored );
471 }
472 }
473
474 // Never surface a raw caught-error `.message` to the user (it can carry
475 // network/stack text). Prefer a curated `userMessage` the fetch helper
476 // attached (the brain's `user_message` SSOT), else a safe fallback. Local
477 // twin of src/services/errorCopy.js#userFacingError — the editor bundle is a
478 // classic enqueued script and can't import the React app's module.
479 function errText( e, fallback ) {
480 return ( e && typeof e.userMessage === 'string' && e.userMessage ) ? e.userMessage : fallback;
481 }
482
483 // Generate an image (base64 only — preview before committing to media).
484 // DIRECT to the brain; the Laravel relay was removed (brainUrl required).
485 async function generateImage( prompt, size ) {
486 const { token } = apiBase();
487 const base = ( window.ZIPAI_CONFIG || {} ).brainUrl;
488 if ( ! base ) {
489 const err = new Error( 'Image generation unavailable: brainUrl not configured.' );
490 err.userMessage = 'Image generation isn’t set up on this site yet.';
491 throw err;
492 }
493 const endpoint = base.replace( /\/$/, '' ) + '/inline-edit/image';
494 const res = await fetch( endpoint, {
495 method: 'POST',
496 headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + token },
497 body: JSON.stringify( { prompt, size } ),
498 } );
499 const data = await res.json();
500 // Surface the brain's curated copy (the AI-image plan-limit / "busy"
501 // notice) via `user_message` (SSOT), with `message` as the back-compat
502 // alias — `error` is a machine code like "plan_limit_exceeded".
503 if ( ! res.ok || ! data.success || ! data.base64 ) {
504 const err = new Error( data.error || 'image_generation_failed' );
505 err.userMessage = data.user_message || data.message || 'Image generation failed';
506 throw err;
507 }
508 return { dataUrl: 'data:' + ( data.mime_type || 'image/png' ) + ';base64,' + data.base64 };
509 }
510
511 // Stock-image search via the SaaS proxy (Pexels/Unsplash aggregator).
512 async function searchStock( query ) {
513 const { url, token } = apiBase();
514 const res = await fetch( url + '/unsplash/search?query=' + encodeURIComponent( query ) + '&per_page=12', {
515 headers: { Accept: 'application/json', Authorization: 'Bearer ' + token },
516 } );
517 const data = await res.json();
518 return ( data && data.data && data.data.results ) ? data.data.results : [];
519 }
520
521 // Upload an image (blob) into the WP media library; returns { url, id }.
522 async function uploadBlobToMedia( blob, filename ) {
523 const form = new FormData();
524 form.append( 'file', blob, filename || 'zip-ai-image.png' );
525 const media = await wp.apiFetch( { path: '/wp/v2/media', method: 'POST', body: form } );
526 return { url: media.source_url, id: media.id };
527 }
528 async function uploadFromDataUrl( dataUrl ) {
529 const res = await fetch( dataUrl );
530 return uploadBlobToMedia( await res.blob(), 'zip-ai-image.png' );
531 }
532 async function uploadFromUrl( srcUrl ) {
533 const res = await fetch( srcUrl );
534 if ( ! res.ok ) {
535 throw new Error( 'Could not fetch image' );
536 }
537 return uploadBlobToMedia( await res.blob(), 'zip-ai-stock.jpg' );
538 }
539
540 // ── Text popover ──
541 function TextPanel( props ) {
542 const { caps, onClose, onRun } = props;
543 const [ prompt, setPrompt ] = useState( '' );
544 const [ moreOpen, setMoreOpen ] = useState( false );
545
546 // The popover only TRIGGERS a run, then closes. Streaming + the toolbar
547 // animation are owned by the block wrapper (EraBlockEdit) so they survive
548 // the popover closing.
549 const trigger = ( intent, instruction ) => {
550 onRun( intent, instruction ); onClose();
551 };
552 const submitPrompt = () => {
553 if ( prompt.trim() ) {
554 trigger( 'custom', prompt.trim() );
555 }
556 };
557
558 if ( ! caps.text ) {
559 return el( 'div', { className: 'eqe-menu' } );
560 }
561
562 const ops = caps.text.ops;
563 const primary = PRIMARY_INTENTS.filter( ( id ) => ops.indexOf( id ) !== -1 );
564 const more = MORE_INTENTS.filter( ( id ) => ops.indexOf( id ) !== -1 );
565
566 // Primary actions — staged horizontal toolbar-style buttons (unchanged).
567 const actBtn = ( id ) => el( 'button', { key: id, className: 'eqe-act', onClick: () => trigger( id ) },
568 el( 'span', { className: 'eqe-ai' }, INTENT_META[ id ].icon() ),
569 INTENT_META[ id ].label,
570 );
571 // "More tones & styles" reveal — same horizontal icon+label structure as
572 // the primary row, just under a MORE label + a Show less link.
573 const linkRow = ( open ) => el( 'button', {
574 key: open ? 'more' : 'less', className: 'eqe-morelink', onClick: () => setMoreOpen( open ), 'aria-expanded': open,
575 },
576 ic( open ? [ p( 'M12 5v14', 'a' ), p( 'M5 12h14', 'b' ) ] : [ p( 'M18 6 6 18', 'a' ), p( 'm6 6 12 12', 'b' ) ] ),
577 open ? __( 'More tones & styles', 'zip-ai' ) : __( 'Show less', 'zip-ai' ),
578 );
579
580 let moreSection = null;
581 if ( more.length ) {
582 moreSection = moreOpen
583 ? el( 'div', { className: 'eqe-moreopen' },
584 el( 'div', { className: 'eqe-morelabel' }, __( 'More', 'zip-ai' ) ),
585 el( 'div', { className: 'eqe-actions-row' }, more.map( actBtn ) ),
586 linkRow( false ) )
587 : linkRow( true );
588 }
589
590 return el( 'div', { className: 'eqe-menu' },
591 el( 'div', { className: 'eqe-promptRow' },
592 el( 'div', { className: 'eqe-promptField' },
593 el( 'span', { className: 'eqe-spark' }, EraMark() ),
594 el( 'input', {
595 className: 'eqe-promptInput', type: 'text',
596 placeholder: __( 'Tell ZIP AI what to change…', 'zip-ai' ),
597 value: prompt, onChange: ( e ) => setPrompt( e.target.value ),
598 onKeyDown: ( e ) => {
599 if ( e.key === 'Enter' ) {
600 e.preventDefault(); submitPrompt();
601 }
602 },
603 } ),
604 el( 'button', { className: 'eqe-send' + ( prompt.trim() ? ' ready' : '' ), onClick: submitPrompt, 'aria-label': __( 'Send', 'zip-ai' ) },
605 ic( [ p( 'm5 12 7-7 7 7', 'a' ), p( 'M12 19V5', 'b' ) ] ) ), // arrow-up
606 ) ),
607 el( 'div', { className: 'eqe-divider' } ),
608 el( 'div', { className: 'eqe-actions-row' }, primary.map( actBtn ) ),
609 moreSection,
610 );
611 }
612
613 // ── Image popover (Generate / Search) ──
614 function ImagePanel( props ) {
615 const { setAttributes, onClose } = props;
616 // Per-block image attribute names (descriptor from imageDescriptorFor).
617 const d = ( props.caps && props.caps.image ) || { url: 'url', id: 'id', alt: 'alt' };
618 const applyImage = ( url, id, alt ) => {
619 const a = {}; a[ d.url ] = url; a[ d.id ] = id; if ( d.alt ) {
620 a[ d.alt ] = alt;
621 }
622 setAttributes( a ); onClose();
623 };
624 const [ mode, setMode ] = useState( 'generate' ); // generate | search
625 const [ imgPrompt, setImgPrompt ] = useState( '' );
626 const [ ratio, setRatio ] = useState( '1536x1024' );
627 const [ styleChip, setStyleChip ] = useState( null );
628 const [ busy, setBusy ] = useState( false );
629 const [ error, setError ] = useState( '' );
630 const [ genPreview, setGenPreview ] = useState( null ); // dataUrl
631 const [ query, setQuery ] = useState( '' );
632 const [ results, setResults ] = useState( [] );
633 const [ searching, setSearching ] = useState( false );
634
635 const doGenerate = useCallback( () => {
636 if ( ! imgPrompt.trim() ) {
637 return;
638 }
639 const full = styleChip ? ( imgPrompt.trim() + ', ' + styleChip + ' style' ) : imgPrompt.trim();
640 setBusy( true ); setError( '' ); setGenPreview( null );
641 generateImage( full, ratio )
642 .then( ( { dataUrl } ) => {
643 setGenPreview( dataUrl ); setBusy( false );
644 } )
645 .catch( ( e ) => {
646 setError( errText( e, 'Couldn’t generate the image. Please try again.' ) ); setBusy( false );
647 } );
648 }, [ imgPrompt, styleChip, ratio ] );
649
650 const useGenerated = useCallback( () => {
651 if ( ! genPreview ) {
652 return;
653 }
654 setBusy( true );
655 uploadFromDataUrl( genPreview )
656 .then( ( { url, id } ) => {
657 applyImage( url, id, imgPrompt.trim() );
658 } )
659 .catch( ( e ) => {
660 setError( errText( e, 'Couldn’t add the image. Please try again.' ) ); setBusy( false );
661 } );
662 }, [ genPreview, imgPrompt, setAttributes, onClose ] );
663
664 const doSearch = useCallback( () => {
665 if ( ! query.trim() ) {
666 return;
667 }
668 setSearching( true ); setError( '' ); setResults( [] );
669 searchStock( query.trim() )
670 .then( ( r ) => {
671 setResults( r ); setSearching( false );
672 } )
673 .catch( ( e ) => {
674 setError( errText( e, 'Couldn’t search stock photos. Please try again.' ) ); setSearching( false );
675 } );
676 }, [ query ] );
677
678 const pickStock = useCallback( ( item ) => {
679 const src = item.urls && ( item.urls.regular || item.urls.small || item.urls.thumb );
680 if ( ! src ) {
681 return;
682 }
683 setBusy( true );
684 uploadFromUrl( src )
685 .then( ( { url, id } ) => {
686 applyImage( url, id, item.alt_description || query );
687 } )
688 .catch( ( e ) => {
689 setError( errText( e, 'Couldn’t add the image. Please try again.' ) ); setBusy( false );
690 } );
691 }, [ query, setAttributes, onClose ] );
692
693 const seg = el( 'div', { className: 'eqe-seg' },
694 el( 'button', { className: 'eqe-segBtn' + ( mode === 'generate' ? ' on' : '' ), onClick: () => setMode( 'generate' ) }, cameraIcon(), __( 'Generate', 'zip-ai' ) ),
695 el( 'button', { className: 'eqe-segBtn' + ( mode === 'search' ? ' on' : '' ), onClick: () => setMode( 'search' ) }, searchIcon(), __( 'Search', 'zip-ai' ) ),
696 );
697
698 let body;
699 if ( mode === 'generate' ) {
700 body = el( 'div', { className: 'eqe-imgBody' },
701 el( 'textarea', {
702 className: 'eqe-ta', rows: 3, placeholder: __( 'Describe the image you want…', 'zip-ai' ),
703 value: imgPrompt, onChange: ( e ) => setImgPrompt( e.target.value ),
704 } ),
705 el( 'div', { className: 'eqe-fieldLabel' }, __( 'Style', 'zip-ai' ) ),
706 el( 'div', { className: 'eqe-swatchRow' }, STYLE_PRESETS.map( ( s ) =>
707 el( 'button', {
708 key: s.id, className: 'eqe-swatch' + ( styleChip === s.id ? ' on' : '' ),
709 'aria-pressed': styleChip === s.id, onClick: () => setStyleChip( styleChip === s.id ? null : s.id ),
710 },
711 el( 'span', { className: 'eqe-swatchImg', style: { backgroundImage: s.grad } }, s.icon() ),
712 el( 'span', { className: 'eqe-swatchLbl' }, s.label ) ) ) ),
713 el( 'div', { className: 'eqe-fieldLabel' }, __( 'Aspect ratio', 'zip-ai' ) ),
714 el( 'div', { className: 'eqe-ratioRow' }, RATIOS.map( ( r ) =>
715 el( 'button', { key: r.id, className: 'eqe-ratio' + ( ratio === r.id ? ' on' : '' ), onClick: () => setRatio( r.id ) },
716 el( 'span', { className: 'eqe-box', style: { width: r.w, height: r.h } } ),
717 el( 'span', { className: 'eqe-rl' }, r.label ) ) ) ),
718 el( 'button', { className: 'eqe-primary', disabled: busy || ! imgPrompt.trim(), onClick: doGenerate },
719 ( ! genPreview && busy ) ? spinner( 'on-dark' ) : cameraIcon(),
720 ( ! genPreview && busy ) ? __( 'Generating…', 'zip-ai' ) : genPreview ? __( 'Regenerate', 'zip-ai' ) : __( 'Generate image', 'zip-ai' ) ),
721 genPreview ? el( 'div', { className: 'eqe-genResult' + ( busy ? ' busy' : '' ) },
722 el( 'img', { src: genPreview, alt: '', style: { width: '100%', display: 'block' } } ),
723 el( 'button', { className: 'eqe-useOverlay', disabled: busy, onClick: useGenerated },
724 busy ? spinner( 'on-dark' ) : checkIcon(),
725 busy ? __( 'Replacing…', 'zip-ai' ) : __( 'Replace', 'zip-ai' ) ) ) : null,
726 error ? el( 'p', { className: 'eqe-errline' }, error ) : null,
727 );
728 } else {
729 body = el( 'div', { className: 'eqe-imgBody' },
730 el( 'div', { className: 'eqe-searchField' + ( searching ? ' busy' : '' ) },
731 // The search icon itself becomes the loader while a query runs —
732 // no separate spinner row, so the field stays the focal point.
733 searching ? spinner() : searchIcon(),
734 el( 'input', {
735 type: 'text', placeholder: __( 'Search free stock photos…', 'zip-ai' ),
736 value: query, onChange: ( e ) => setQuery( e.target.value ),
737 onKeyDown: ( e ) => {
738 if ( e.key === 'Enter' ) {
739 e.preventDefault(); doSearch();
740 }
741 },
742 } ) ),
743 searching ? el( 'div', { className: 'eqe-grid eqe-skel' },
744 [ 0, 1, 2, 3, 4, 5 ].map( ( i ) => el( 'span', { key: i, className: 'eqe-skelcell' } ) ) ) : null,
745 results.length ? el( 'div', { className: 'eqe-grid' }, results.map( ( item, i ) =>
746 el( 'button', { key: item.id || i, className: 'eqe-thumb', onClick: () => pickStock( item ), disabled: busy },
747 el( 'img', { src: ( item.urls && ( item.urls.thumb || item.urls.small ) ) || '', alt: item.alt_description || '', loading: 'lazy' } ) ) ) ) : null,
748 results.length ? el( 'p', { className: 'eqe-credit' }, __( 'Photos via Pexels / Unsplash', 'zip-ai' ) ) : null,
749 error ? el( 'p', { className: 'eqe-errline' }, error ) : null,
750 );
751 }
752 return el( 'div', { className: 'eqe-imgPanel' }, seg, body );
753 }
754
755 function QuickEditPanel( props ) {
756 if ( props.caps.image ) {
757 return el( ImagePanel, props );
758 }
759 return el( TextPanel, props );
760 }
761
762 // Per-editable-block wrapper. OWNS the streaming text run so it outlives the
763 // popover: the popover only triggers run(); the stream, the typewriter
764 // reveal, the skeleton (until the first token) and the toolbar loading state
765 // all live here. Errors surface as a Gutenberg snackbar notice — no popover.
766 function EraBlockEdit( opts ) {
767 const BlockEdit = opts.BlockEdit,
768 props = opts.props,
769 caps = opts.caps;
770 const { name, attributes, setAttributes } = props;
771 const [ running, setRunning ] = useState( false );
772 const [ progress, setProgress ] = useState( null ); // section edit: { cur, total }
773 const abortRef = useRef( null );
774 const timerRef = useRef( null );
775
776 const markTransient = useCallback( () => {
777 try {
778 wp.data.dispatch( 'core/block-editor' ).__unstableMarkNextChangeAsNotPersistent();
779 } catch ( e ) {}
780 }, [] );
781 const notifyError = useCallback( ( msg ) => {
782 try {
783 wp.data.dispatch( 'core/notices' ).createErrorNotice( msg, { type: 'snackbar', isDismissible: true } );
784 } catch ( e ) {}
785 }, [] );
786
787 // Abort + clear on unmount.
788 useEffect( () => () => {
789 if ( abortRef.current ) {
790 abortRef.current.abort();
791 }
792 if ( timerRef.current ) {
793 clearInterval( timerRef.current );
794 }
795 }, [] );
796
797 const cancel = useCallback( () => {
798 if ( abortRef.current ) {
799 abortRef.current.abort();
800 }
801 }, [] );
802
803 // Edit a block or a whole section (a single block is a list of one). A
804 // large section is bin-packed into batches that each fit one call, fetched
805 // SEQUENTIALLY (one connection at a time), and revealed as each arrives: a
806 // choreographed SPOTLIGHT pass types each changed block in turn (shimmer →
807 // glow + scroll → typewriter → done). Each block has a unique id and lives
808 // in exactly one batch, so the per-id buffer never conflicts. One persistent
809 // write per block; cancel reverts everything, a mid-way failure keeps the
810 // batches already applied. Toolbar shows "Editing N of M" across the section.
811 const run = useCallback( ( intent, instruction ) => {
812 if ( abortRef.current ) {
813 return;
814 }
815 // Every editable text target in scope (own fields + descendants, any
816 // block type). A target = one (block, field): a block with several
817 // text fields contributes several targets, each keyed by `key`
818 // (clientId::field) since the wire keys edits by id.
819 // Drop any single field whose own est-cost already exceeds the
820 // per-call ceiling — it can't fit a batch and the brain would reject
821 // it, failing the whole pass; skipping it lets the rest still edit.
822 const all = collectTargets( props.clientId );
823 const targets = all.filter(
824 ( t ) => Math.ceil( t.text.length / 2 ) + 64 + 4096 <= SECTION_COST_CEILING );
825 if ( ! targets.length ) {
826 // Don't fail silently: if there WAS text but every field is over
827 // the ceiling, tell the user instead of a no-op.
828 if ( all.length ) {
829 notifyError( __( 'This text is too long for ZIP AI to edit in one pass.', 'zip-ai' ) );
830 }
831 return;
832 }
833 const byKey = {}; targets.forEach( ( t ) => {
834 byKey[ t.key ] = t;
835 } );
836 const batches = partitionTargets( targets ); // each ≤ cost ceiling & ≤ count cap
837 // Gather the page outline ONCE (same for every batch of this run) so a
838 // multi-batch section doesn't re-walk the block tree per batch.
839 const pageOutline = collectPageOutline();
840
841 const ac = new AbortController(); abortRef.current = ac;
842 setRunning( true );
843 const dispatch = wp.data.dispatch( 'core/block-editor' );
844 const setText = ( id, field, value ) => {
845 const a = {}; a[ field ] = value; dispatch.updateBlockAttributes( id, a );
846 };
847 const latest = {}; // key -> final text, buffered per batch
848 targets.forEach( ( t ) => setBlockState( t.id, 'pending' ) ); // all waiting → shimmer
849 setProgress( { cur: 0, total: targets.length } );
850
851 // Reveal one target: spotlight + scroll its block, typewriter at a
852 // steady cadence into its field, final persistent write, done flash.
853 const reveal = ( t ) => new Promise( ( resolve ) => {
854 const full = latest[ t.key ];
855 setBlockState( t.id, 'active' );
856 // A value containing HTML tags CANNOT be char-sliced: an
857 // intermediate slice lands mid-tag (e.g. `…></span><span>`), which
858 // is invalid markup — and if the reveal is interrupted there, the
859 // block freezes in that broken state and loses content. Write rich
860 // values in one shot; only plain text gets the typewriter.
861 if ( /<[a-z!/][^>]*>/i.test( full ) ) {
862 setText( t.id, t.field, full );
863 setBlockState( t.id, 'done' );
864 resolve();
865 return;
866 }
867 let shown = 0;
868 timerRef.current = setInterval( () => {
869 if ( ac.signal.aborted ) {
870 clearInterval( timerRef.current ); timerRef.current = null; resolve(); return;
871 }
872 shown = Math.min( full.length, shown + Math.max( 2, Math.ceil( ( full.length - shown ) / 8 ) ) );
873 markTransient(); setText( t.id, t.field, full.slice( 0, shown ) );
874 if ( shown >= full.length ) {
875 clearInterval( timerRef.current ); timerRef.current = null;
876 setText( t.id, t.field, full ); // persist final
877 setBlockState( t.id, 'done' );
878 resolve();
879 }
880 }, 30 );
881 } );
882 const revertAll = () => {
883 targets.forEach( ( t ) => setBlockState( t.id, null ) );
884 Object.keys( latest ).forEach( ( key ) => {
885 const t = byKey[ key ]; if ( t ) {
886 markTransient(); setText( t.id, t.field, t.text );
887 }
888 } );
889 };
890
891 ( async () => {
892 let revealed = 0; // targets the brain actually changed
893 let processed = 0; // all targets handled — drives the N-of-M counter
894 try {
895 for ( let b = 0; b < batches.length; b += 1 ) {
896 if ( ac.signal.aborted ) {
897 break;
898 }
899 const batch = batches[ b ];
900 const payload = { intent, blocks: batch.map( ( t ) => ( { id: t.key, block_type: t.name, text: t.text } ) ) };
901 if ( intent === 'custom' ) {
902 payload.instruction = instruction;
903 }
904 if ( pageOutline ) {
905 payload.page_outline = pageOutline;
906 }
907 // Fetch this batch (fills the buffer), then reveal its targets.
908 await streamEdit( payload, ( key, text ) => {
909 if ( byKey[ key ] ) {
910 latest[ key ] = text;
911 }
912 }, ac.signal ); // eslint-disable-line no-await-in-loop
913 for ( let i = 0; i < batch.length; i += 1 ) {
914 if ( ac.signal.aborted ) {
915 break;
916 }
917 const t = batch[ i ];
918 // Count every target (changed or not) so the counter
919 // reaches M even when some blocks come back unchanged.
920 processed += 1; setProgress( { cur: processed, total: targets.length } );
921 if ( latest[ t.key ] !== null && latest[ t.key ] !== undefined && latest[ t.key ] !== t.text ) {
922 revealed += 1;
923 await reveal( t ); // eslint-disable-line no-await-in-loop
924 } else {
925 setBlockState( t.id, null ); // unchanged → drop shimmer
926 }
927 }
928 // Brief gap before the next batch so a multi-batch section
929 // doesn't machine-gun the provider into a rate limit.
930 if ( b < batches.length - 1 && ! ac.signal.aborted ) {
931 await new Promise( ( r ) => setTimeout( r, BATCH_GAP_MS ) ); // eslint-disable-line no-await-in-loop
932 }
933 }
934 abortRef.current = null; setRunning( false ); setProgress( null );
935 if ( ac.signal.aborted ) {
936 revertAll(); return;
937 }
938 if ( revealed === 0 ) {
939 notifyError( __( 'No changes were needed.', 'zip-ai' ) );
940 }
941 } catch ( e ) {
942 abortRef.current = null; setRunning( false ); setProgress( null );
943 if ( ac.signal.aborted ) {
944 revertAll(); return;
945 }
946 // Partial failure: keep batches already applied; just clear the
947 // shimmer on targets we never reached.
948 targets.forEach( ( t ) => {
949 if ( latest[ t.key ] === null || latest[ t.key ] === undefined ) {
950 setBlockState( t.id, null );
951 }
952 } );
953 notifyError( errText( e, __( 'ZIP AI couldn’t finish this section. Please try again.', 'zip-ai' ) ) );
954 }
955 } )();
956 }, [ props.clientId, markTransient, notifyError ] );
957
958 // caps null = no ZIP AI affordance for this block (yet) — render the
959 // block untouched. Hooks above still run so the type stays mounted.
960 const toolbar = caps && el( BlockControls, { group: 'other' },
961 el( ToolbarGroup, null,
962 running
963 ? el( ToolbarButton, {
964 label: __( 'Stop', 'zip-ai' ), showTooltip: true,
965 onClick: cancel, className: 'eqe-toggle eqe-running',
966 },
967 // Loader + explicit "editing" copy + animated dots so it
968 // reads unmistakably as work-in-progress, not a static icon.
969 el( 'span', { className: 'eqe-workwrap' },
970 EraLoader(),
971 el( 'span', { className: 'eqe-worktext' },
972 ( progress && progress.total > 1 )
973 ? sprintf( /* translators: %1$d is the current item number, %2$d is the total number of items. */ __( 'Editing %1$d of %2$d', 'zip-ai' ), progress.cur || 1, progress.total )
974 : __( 'ZIP AI is editing', 'zip-ai' ) ),
975 el( 'span', { className: 'eqe-dots', 'aria-hidden': 'true' },
976 el( 'i', null ), el( 'i', null ), el( 'i', null ) ) ) )
977 : el( Dropdown, {
978 // bottom-end opens the panel leftward, into the canvas and
979 // away from the docked ZIP AI sidebar — a right-edge block's
980 // bottom-start panel slid under it. flip still picks the
981 // vertical side with room (toolbar floats high on some
982 // blocks, low on others); shift clamps the panel back into
983 // view near an edge. The panel keeps a stable compact height
984 // (eqe-imgBody min-height + grid cap below) so flip doesn't
985 // ping-pong between tabs, and content scrolls rather than
986 // clipping when space is tight (resize+overflow).
987 popoverProps: { placement: 'bottom-end', flip: true, shift: true },
988 className: 'eqe-dropdown', contentClassName: 'eqe-popover',
989 renderToggle: ( { isOpen, onToggle } ) => el( ToolbarButton, {
990 icon: EraMark(), text: __( 'Ask ZIP AI', 'zip-ai' ), label: __( 'Ask ZIP AI', 'zip-ai' ),
991 showTooltip: true, onClick: onToggle, 'aria-expanded': isOpen, className: 'eqe-toggle',
992 } ),
993 renderContent: ( { onClose } ) => el( QuickEditPanel, {
994 name, attributes, setAttributes, caps, onClose, onRun: run,
995 } ),
996 } )
997 )
998 );
999
1000 // No wrapper around BlockEdit — Gutenberg blocks break when their edit
1001 // output is nested in an extra element. Run state lives in this
1002 // component (survives the popover closing); the block streams in place.
1003 return el( wp.element.Fragment, null,
1004 el( BlockEdit, props ),
1005 toolbar,
1006 );
1007 }
1008
1009 const withEraQuickEdit = createHigherOrderComponent( ( BlockEdit ) => {
1010 return ( props ) => {
1011 // One uniform decision: collect every editable text target in this
1012 // block's scope (own fields + descendants, any block type). Show
1013 // "Ask ZIP AI" if there's text within the cap, or it's an image block.
1014 // REACTIVE gate, cheap steady-state: subscribe to the deep block
1015 // tree ONLY while this block has no quick-edit affordance yet
1016 // (fresh empty block), so the button appears the moment text lands
1017 // — typed or streamed — without a reload. Once targets exist the
1018 // mapSelect returns a constant and the subscription goes quiet:
1019 // during a streaming quick edit (~30 descendant attr updates/s)
1020 // ancestor wrappers do NOT re-walk their subtree per tick.
1021 // Structural changes still refresh via descIds (render-computed);
1022 // the block's own fields via props.attributes.
1023 const hasTargetsRef = useRef( false );
1024 const emptyTree = wp.data.useSelect(
1025 ( select ) => hasTargetsRef.current ? null : select( 'core/block-editor' ).getBlocks( props.clientId ),
1026 [ props.clientId ]
1027 );
1028 const descIds = ( () => {
1029 const be = wp.data.select( 'core/block-editor' );
1030 return be && typeof be.getClientIdsOfDescendants === 'function'
1031 ? be.getClientIdsOfDescendants( [ props.clientId ] ).join( '|' ) : '';
1032 } )();
1033 const targets = useMemo( () => collectTargets( props.clientId ), [ props.clientId, descIds, emptyTree, props.attributes ] );
1034 hasTargetsRef.current = targets.length > 0;
1035 const hasText = targets.length > 0 && targets.length <= MAX_SECTION_TARGETS;
1036 // Image descriptor (or null). Skip a media block currently in video
1037 // mode (e.g. core/media-text with mediaType:'video').
1038 const imageDesc = props.attributes && props.attributes.mediaType === 'video'
1039 ? null : imageDescriptorFor( props.name );
1040 // The image panel is shown ONLY for a leaf image block (core/image,
1041 // even one with a caption). A section CONTAINER that also carries an
1042 // image (core/cover, core/media-text) has editable text in its
1043 // descendants — there the section text-rewrite is the point, so the
1044 // image panel must not shadow it.
1045 const hasDescendantText = targets.some( ( t ) => t.id !== props.clientId );
1046 const imagePrimary = !! imageDesc && ! hasDescendantText;
1047 // ALWAYS render EraBlockEdit (caps:null → plain BlockEdit, no
1048 // toolbar). The component type at this slot must be stable: now
1049 // that the gate is reactive it flips on the first typed char, and
1050 // swapping bare BlockEdit ↔ wrapper on a flip remounts the block's
1051 // edit tree mid-typing (caret/IME loss). Conditional lives inside.
1052 return el( EraBlockEdit, {
1053 BlockEdit, props,
1054 caps: ( hasText || imagePrimary ) ? {
1055 text: ( hasText && ! imagePrimary ) ? { ops: TEXT_FULL } : null,
1056 image: imagePrimary ? imageDesc : false,
1057 } : null,
1058 } );
1059 };
1060 }, 'withEraQuickEdit' );
1061
1062 addFilter( 'editor.BlockEdit', 'zip-ai/quick-edit', withEraQuickEdit );
1063
1064 // One-time scoped styles for the quick-edit popover (ZIP AI design tokens).
1065 ( function injectQuickEditStyles() {
1066 if ( document.getElementById( 'eqe-styles' ) ) {
1067 return;
1068 }
1069 const s = document.createElement( 'style' );
1070 s.id = 'eqe-styles';
1071 s.textContent =
1072 // Accent maps to the ZIP AI ink system (no blue): primary/active = ink-1,
1073 // soft ring = translucent ink, soft fill = sunken.
1074 '.eqe-popover{--ai:#1A1D21;--ai-50:#F6F7F8;--ink0:#0A0A0C;--ink1:#1A1D21;--ink3:#6B7178;--ink4:#9AA0A6;--ink5:#BCC0C5;--line:#ECECEE;--line2:#E2E2E5;--line3:#D3D4D7;--sunken:#F6F7F8}' +
1075 // overflow:auto (not hidden) so when flip/resize constrains the popover
1076 // near a viewport edge the panel scrolls instead of clipping its top.
1077 '.eqe-popover .components-popover__content{border-radius:14px!important;border:1px solid var(--line2)!important;box-shadow:0 18px 44px -16px rgba(16,18,20,.26),0 4px 12px -6px rgba(16,18,20,.12)!important;overflow:auto}' +
1078 // Toolbar-width panel: prompt on top, one wrapping action row.
1079 '.eqe-menu{width:344px;max-width:92vw;padding:0 0 7px}' +
1080 '.eqe-promptRow{padding:11px 11px 9px}' +
1081 '.eqe-promptField{display:flex;align-items:center;gap:9px;border:1.5px solid var(--line2);border-radius:11px;padding:0 6px 0 12px;height:44px;transition:border-color .14s,box-shadow .14s}' +
1082 '.eqe-promptField:focus-within{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-50)}' +
1083 // Kill wp-admin borders/rings on the bare inputs (focused or not) so
1084 // only the wrapper shows a single border — no nested box.
1085 '.eqe-popover .eqe-promptField input,.eqe-popover .eqe-searchField input{border:none!important;box-shadow:none!important;outline:none!important;background:transparent!important}' +
1086 '.eqe-spark{color:var(--ai);display:flex;flex-shrink:0}.eqe-spark svg{width:18px;height:18px}' +
1087 '.eqe-promptInput{flex:1;border:none;outline:none;background:transparent;font-size:13.5px;color:var(--ink1);min-width:0}' +
1088 // wp-admin slaps a blue focus ring on every input/textarea — kill it
1089 // inside the popover so only OUR ink ring (on the wrappers) shows.
1090 '.eqe-popover input:focus,.eqe-popover .eqe-promptInput:focus,.eqe-popover .eqe-searchField input:focus{outline:none!important;box-shadow:none!important;border:none!important;background:transparent!important}' +
1091 '.eqe-popover .eqe-ta:focus{border-color:var(--ai)!important;box-shadow:0 0 0 3px var(--ai-soft)!important;outline:none!important}' +
1092 '.eqe-send{width:32px;height:32px;border-radius:9px;border:none;cursor:pointer;display:grid;place-items:center;flex-shrink:0;background:var(--sunken);color:var(--ink5);transition:all .14s}' +
1093 '.eqe-send svg{width:17px;height:17px}' +
1094 '.eqe-send.ready{background:var(--ai);color:#fff}' +
1095 '.eqe-send.ready:hover{transform:translateY(-1px)}' +
1096 '.eqe-divider{height:1px;background:var(--line);margin:0 11px}' +
1097 // Primary actions — staged horizontal toolbar-style buttons.
1098 '.eqe-actions-row{display:flex;flex-wrap:wrap;gap:3px;padding:8px}' +
1099 '.eqe-act{display:inline-flex;align-items:center;gap:7px;padding:7px 10px;border:none;background:none;border-radius:8px;cursor:pointer;color:var(--ink1);font-size:12.5px;font-weight:500;transition:background .12s}' +
1100 '.eqe-act:hover{background:var(--sunken)}' +
1101 '.eqe-act .eqe-ai{width:15px;height:15px;flex-shrink:0;color:var(--ink3);display:flex}.eqe-act:hover .eqe-ai{color:var(--ai)}.eqe-act .eqe-ai svg{width:15px;height:15px}' +
1102 // "More tones & styles" reveal — same horizontal action row as primary.
1103 '.eqe-moreopen{border-top:1px solid var(--line);margin-top:2px}' +
1104 // "+ More tones & styles" / "Show less" accent link row (ZIP AI ink).
1105 '.eqe-morelabel{font-size:9.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink4);padding:7px 9px 3px}' +
1106 '.eqe-morelink{display:flex;align-items:center;gap:8px;width:100%;padding:6px 7px;border:none;background:none;border-radius:9px;cursor:pointer;text-align:left;color:var(--ai);font-size:12.5px;font-weight:600;transition:background .12s}' +
1107 '.eqe-morelink:hover{background:var(--sunken)}' +
1108 '.eqe-morelink svg{width:15px;height:15px;flex-shrink:0;margin:0 6px}' +
1109 // Ink spinner ring — shared by search field + Generate button.
1110 '.eqe-spin{width:16px;height:16px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--ai);box-sizing:border-box;flex-shrink:0;display:inline-block;animation:eqe-rot .7s linear infinite}' +
1111 '.eqe-spin.on-dark{border-color:rgba(255,255,255,.35);border-top-color:#fff}' +
1112 '@keyframes eqe-rot{to{transform:rotate(360deg)}}' +
1113 // "ZIP AI is editing" toolbar state — loader + copy + bouncing dots.
1114 '.eqe-workwrap{display:inline-flex;align-items:center;gap:8px}' +
1115 '.eqe-worktext{font-size:12.5px;font-weight:600;color:var(--ai);white-space:nowrap}' +
1116 '.eqe-dots{display:inline-flex;align-items:flex-end;gap:3px;padding-bottom:2px}' +
1117 '.eqe-dots i{width:3px;height:3px;border-radius:50%;background:var(--ai);animation:eqe-dot 1.2s ease-in-out infinite}' +
1118 '.eqe-dots i:nth-child(2){animation-delay:.18s}.eqe-dots i:nth-child(3){animation-delay:.36s}' +
1119 '@keyframes eqe-dot{0%,70%,100%{opacity:.3;transform:translateY(0)}35%{opacity:1;transform:translateY(-3px)}}' +
1120 // ZIP AI-mark loader: same ink knockout tile as the Ask button (the
1121 // mark must not change color while editing) + a comet segment
1122 // running its border.
1123 '.eqe-loader{position:relative;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:#FF580E}' +
1124 '.eqe-loader .eqe-snake-glyph{display:block}' +
1125 // track + ring use the border-mask trick (content-box xor) so only the
1126 // 1.6px frame paints; the ring sweeps via the animated --eqe-a angle.
1127 '.eqe-snake-track,.eqe-snake-ring{position:absolute;inset:0;border-radius:inherit;padding:1.6px;pointer-events:none;-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;mask-composite:exclude}' +
1128 '.eqe-snake-track{background:rgba(255,255,255,.08)}' +
1129 '.eqe-snake-ring{background:conic-gradient(from var(--eqe-a,0deg),#FFE7D5 0deg,#FF9A5C 14deg,#FF580E 30deg,rgba(255,88,14,.35) 70deg,rgba(255,88,14,0) 110deg,transparent 360deg);filter:drop-shadow(0 0 3px rgba(255,120,60,.7));animation:eqe-snake 1.5s cubic-bezier(.45,.05,.55,.95) infinite}' +
1130 '@property --eqe-a{syntax:"<angle>";inherits:false;initial-value:0deg}' +
1131 '@keyframes eqe-snake{to{--eqe-a:360deg}}' +
1132 '@media (prefers-reduced-motion:reduce){.eqe-snake-ring,.eqe-spin,.eqe-skelcell,.eqe-dots i{animation:none}}' +
1133 '.eqe-primary{flex:1;min-width:120px;padding:11px;border:none;border-radius:11px;background:var(--ai);color:#fff;font-size:13.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:8px;box-shadow:inset 0 1px 0 rgba(255,255,255,.18);transition:filter .12s}' +
1134 '.eqe-primary svg{width:16px;height:16px}' +
1135 '.eqe-primary:hover:not(:disabled){filter:brightness(1.06)}' +
1136 // Block skeleton — a shimmer overlay on the editing block(s) until
1137 // the first streamed token. Targets the block\'s own [data-block]
1138 // node (no wrapper around BlockEdit). Global, not popover-scoped.
1139 '[data-block].eqe-skeleton-on{position:relative}' +
1140 '[data-block].eqe-skeleton-on::after{content:"";position:absolute;inset:0;z-index:6;border-radius:6px;pointer-events:none;background:#F2F3F5;background-image:linear-gradient(90deg,#F2F3F5 0,#E6E8EB 80px,#F2F3F5 160px);background-size:300px 100%;background-repeat:no-repeat;animation:eqe-shimmer 1.1s linear infinite}' +
1141 '@keyframes eqe-shimmer{0%{background-position:-160px 0}100%{background-position:300px 0}}' +
1142 '@media (prefers-reduced-motion:reduce){[data-block].eqe-skeleton-on::after{animation:none}}' +
1143 // Sequential spotlight — the block being written gets a pulsing accent
1144 // ring; on hand-off it flashes a green "done" ring that fades. Rings
1145 // paint on ::before (skeleton owns ::after) so the two never clash.
1146 '[data-block].eqe-block-active{position:relative}' +
1147 '[data-block].eqe-block-active::before{content:"";position:absolute;inset:-4px;z-index:7;border-radius:9px;pointer-events:none;box-shadow:0 0 0 2px #FF580E,0 0 0 7px rgba(255,88,14,.16);animation:eqe-activepulse 1.4s ease-in-out infinite}' +
1148 '@keyframes eqe-activepulse{0%,100%{opacity:.6}50%{opacity:1}}' +
1149 '[data-block].eqe-block-done::before{content:"";position:absolute;inset:-4px;z-index:7;border-radius:9px;pointer-events:none;box-shadow:0 0 0 2px #2BA24C,0 0 0 7px rgba(43,162,76,.14);animation:eqe-doneflash .9s ease-out forwards}' +
1150 '@keyframes eqe-doneflash{0%{opacity:1}100%{opacity:0}}' +
1151 '@media (prefers-reduced-motion:reduce){[data-block].eqe-block-active::before,[data-block].eqe-block-done::before{animation:none}}' +
1152 '.eqe-imgBody>.eqe-primary{width:100%;margin-top:16px}' +
1153 '.eqe-primary:disabled{opacity:.6;cursor:default}' +
1154 '.eqe-genResult::after{content:"";position:absolute;inset:0;background:rgba(0,0,0,.14);opacity:0;transition:opacity .15s;pointer-events:none}' +
1155 '.eqe-genResult:hover::after,.eqe-genResult.busy::after{opacity:1}' +
1156 '.eqe-useOverlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:2;display:inline-flex;align-items:center;gap:8px;padding:11px 20px;border:none;border-radius:999px;background:#fff;color:var(--ink0);font-size:13.5px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.22);opacity:0;transition:opacity .15s}' +
1157 '.eqe-genResult:hover .eqe-useOverlay,.eqe-genResult.busy .eqe-useOverlay{opacity:1}' +
1158 '.eqe-useOverlay svg{width:16px;height:16px}' +
1159 '.eqe-useOverlay:disabled{cursor:default}' +
1160 '.eqe-error,.eqe-errline{color:#b32d2e;font-size:13px}.eqe-errline{margin:10px 2px 0}' +
1161 // Image panel stays on the ZIP AI ink system (no blue): --ai/--ai-50
1162 // inherit ink-1/sunken from .eqe-popover; only the two image-only
1163 // tokens are mapped to ink — --ai-ink (selected label) + --ink2.
1164 '.eqe-imgPanel{--ai-ink:#0A0A0C;--ai-soft:#F6F7F8;--ink2:#3D4248}' +
1165 '.eqe-seg{display:flex;gap:3px;padding:12px 12px 0}' +
1166 '.eqe-segBtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 0;border:none;background:var(--sunken);color:var(--ink3);font-size:12.5px;font-weight:600;cursor:pointer;border-radius:9px;transition:all .14s}' +
1167 '.eqe-segBtn svg{width:15px;height:15px}' +
1168 '.eqe-segBtn.on{background:var(--ink1);color:#fff}' +
1169 // Floor the body to the Generate-tab height so an empty Search tab
1170 // doesn't collapse — keeps both tabs the same size so flip picks one
1171 // side and stays there instead of jumping when you switch tabs.
1172 '.eqe-imgBody{padding:12px;min-width:300px;max-width:340px;min-height:336px;box-sizing:border-box}' +
1173 '.eqe-ta{width:100%;border:1.5px solid var(--line2);border-radius:11px;padding:10px 12px;font-size:13.5px;line-height:1.5;color:var(--ink1);resize:none;outline:none;min-height:68px;background:#fff;transition:border-color .14s,box-shadow .14s}' +
1174 '.eqe-ta:focus{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-soft)}' +
1175 '.eqe-fieldLabel{font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--ink4);margin:14px 2px 7px}' +
1176 // Style presets — gradient-thumbnail cards (3-up).
1177 '.eqe-swatchRow{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}' +
1178 '.eqe-swatch{display:flex;flex-direction:column;gap:5px;padding:5px;border:1px solid var(--line2);background:#fff;border-radius:10px;cursor:pointer;transition:all .12s}' +
1179 '.eqe-swatch:hover{border-color:var(--line3)}' +
1180 '.eqe-swatch.on{border-color:var(--ai);background:var(--ai-50);box-shadow:0 0 0 1px var(--ai)}' +
1181 '.eqe-swatchImg{height:34px;border-radius:6px;display:grid;place-items:center;color:#fff}' +
1182 '.eqe-swatchImg svg{width:18px;height:18px}' +
1183 '.eqe-swatchLbl{font-size:11px;font-weight:500;color:var(--ink2);text-align:center}' +
1184 '.eqe-swatch.on .eqe-swatchLbl{color:var(--ai-ink)}' +
1185 '.eqe-ratioRow{display:flex;gap:6px}' +
1186 '.eqe-ratio{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px;padding:9px 0 8px;border:1px solid var(--line2);border-radius:9px;background:#fff;cursor:pointer}' +
1187 '.eqe-ratio.on{border-color:var(--ai);background:var(--ai-50)}' +
1188 '.eqe-box{border:1.6px solid var(--ink3);border-radius:2px}.eqe-ratio.on .eqe-box{border-color:var(--ai)}' +
1189 '.eqe-rl{font-size:11px;font-weight:500;color:var(--ink3)}.eqe-ratio.on .eqe-rl{color:var(--ai)}' +
1190 '.eqe-genResult{position:relative;margin-top:12px;border-radius:11px;overflow:hidden}' +
1191 '.eqe-searchField{display:flex;align-items:center;gap:9px;height:42px;padding:0 12px;border:1.5px solid var(--line2);border-radius:11px;color:var(--ink4)}' +
1192 '.eqe-searchField:focus-within{border-color:var(--ai);box-shadow:0 0 0 3px var(--ai-soft)}' +
1193 '.eqe-searchField input{flex:1;border:none;outline:none;background:transparent;font-size:13.5px;color:var(--ink1);min-width:0}' +
1194 // Cap the result grid so the panel stays a fixed compact size: the
1195 // grid scrolls internally instead of stretching the popover to fill
1196 // the viewport (which made it resize/jump as the page scrolled).
1197 '.eqe-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-top:12px;max-height:300px;overflow-y:auto;overflow-x:hidden}' +
1198 // Skeleton thumbnails while a search runs — ink shimmer, no blue spinner.
1199 '.eqe-skelcell{aspect-ratio:1;border-radius:9px;background:linear-gradient(90deg,var(--sunken) 25%,var(--line) 37%,var(--sunken) 63%);background-size:400% 100%;animation:eqe-shim 1.3s ease-in-out infinite}' +
1200 '@keyframes eqe-shim{0%{background-position:100% 0}100%{background-position:-100% 0}}' +
1201 '.eqe-thumb{position:relative;aspect-ratio:1;border-radius:9px;overflow:hidden;cursor:pointer;border:1px solid rgba(0,0,0,.06);padding:0;background:none}' +
1202 '.eqe-thumb img{width:100%;height:100%;object-fit:cover;display:block}' +
1203 '.eqe-thumb:hover{box-shadow:inset 0 0 0 2.5px var(--ai)}' +
1204 '.eqe-credit{font-size:11px;color:var(--ink4);margin-top:11px}';
1205 document.head.appendChild( s );
1206 }() );
1207 }() );
1208