PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
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 / core / block-context-picker.js

block-context-picker.js in ZIP AI – AI Website Builder & AI Agent 0.0.13, at assets/js/core/block-context-picker.js

593 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Block Context Picker
3 *
4 * Adds a "+" pin button on block hover in Gutenberg editor.
5 * Selected blocks are sent as context to the chat assistant.
6 * Supports both classic and iframed block editor (WP 6.3+).
7 *
8 * @since x.x.x
9 */
10
11 ( function() {
12 'use strict';
13
14 /** SVG for the "+" (add) icon */
15 const ICON_ADD = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
16
17 /** SVG for the checkmark (pinned) icon */
18 const ICON_CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
19
20 function BlockContextPicker() {
21 /** @type {Map<string, Object>} clientId → serialized block data */
22 this.contextBlocks = new Map();
23
24 /** @type {HTMLElement|null} Single floating pin button */
25 this.pinButton = null;
26
27 /** @type {string|null} clientId of the block currently being hovered */
28 this.hoveredClientId = null;
29
30 /** @type {boolean} Whether the picker is currently active */
31 this.active = false;
32
33 /** @type {Function|null} wp.data.subscribe unsubscribe handle */
34 this._unsubscribeBlockDeletion = null;
35
36 /** @type {Document} The document where blocks live (parent or iframe) */
37 this._editorDocument = document;
38
39 /** @type {number} Last known block count for quick deletion check */
40 this._lastBlockCount = 0;
41
42 /** @type {Function|null} Bound handler references for cleanup */
43 const self = this;
44 this._onMouseOver = function( e ) {
45 self._handleMouseOver( e );
46 };
47 this._onMouseOut = function( e ) {
48 self._handleMouseOut( e );
49 };
50 this._onPinClick = function( e ) {
51 self._handlePinClick( e );
52 };
53 }
54
55 /**
56 * Enable the picker — called when sidebar opens.
57 *
58 * @since x.x.x
59 */
60 BlockContextPicker.prototype.enable = function() {
61 if ( this.active ) {
62 return;
63 }
64
65 if ( ! window.wp || ! window.wp.data || ! window.wp.data.select( 'core/block-editor' ) ) {
66 return;
67 }
68
69 this.active = true;
70 this._resolveEditorDocument();
71 this._createPinButton();
72 this._bindEditorEvents();
73 this._watchBlockDeletion();
74 };
75
76 /**
77 * Disable the picker — called when sidebar closes.
78 *
79 * @since x.x.x
80 */
81 BlockContextPicker.prototype.disable = function() {
82 if ( ! this.active ) {
83 return;
84 }
85
86 this.active = false;
87 this._removePinButton();
88 this._unbindEditorEvents();
89 this._unwatchBlockDeletion();
90 this.hoveredClientId = null;
91 };
92
93 /**
94 * Clear all pinned context blocks.
95 *
96 * @since x.x.x
97 */
98 BlockContextPicker.prototype.clear = function() {
99 this.contextBlocks.clear();
100 this.hoveredClientId = null;
101 this._removeAllHighlights();
102 this._updateBadge();
103 };
104
105 /**
106 * Get all context blocks as an array for the chat context payload.
107 *
108 * @since x.x.x
109 * @return {Array<Object>}
110 */
111 BlockContextPicker.prototype.getContextBlocks = function() {
112 return Array.from( this.contextBlocks.values() );
113 };
114
115 /**
116 * Check if any blocks are pinned.
117 *
118 * @since x.x.x
119 * @return {boolean}
120 */
121 BlockContextPicker.prototype.hasContextBlocks = function() {
122 return this.contextBlocks.size > 0;
123 };
124
125 // ── Editor document resolution (iframe support) ─────────────────
126
127 /**
128 * Detect whether the block editor uses an iframe canvas (WP 6.3+).
129 *
130 * @since x.x.x
131 */
132 BlockContextPicker.prototype._resolveEditorDocument = function() {
133 const editorIframe = document.querySelector( 'iframe[name="editor-canvas"]' );
134 if ( editorIframe && editorIframe.contentDocument ) {
135 this._editorDocument = editorIframe.contentDocument;
136 } else {
137 this._editorDocument = document;
138 }
139 };
140
141 // ── DOM — pin button ────────────────────────────────────────────
142
143 /**
144 * Create the floating pin button element.
145 *
146 * @since x.x.x
147 */
148 BlockContextPicker.prototype._createPinButton = function() {
149 if ( this.pinButton ) {
150 return;
151 }
152
153 const doc = this._editorDocument;
154 const btn = doc.createElement( 'button' );
155 btn.className = 'zipwp-context-pin';
156 btn.type = 'button';
157 btn.setAttribute( 'aria-label', 'Pin block to chat context' );
158 btn.innerHTML = ICON_ADD;
159
160 btn.addEventListener( 'click', this._onPinClick );
161 doc.body.appendChild( btn );
162 this.pinButton = btn;
163
164 // Inject styles into iframe editor if needed
165 if ( doc !== document ) {
166 this._injectIframeStyles( doc );
167 }
168 };
169
170 /**
171 * Inject context picker CSS into the editor iframe document.
172 *
173 * @since x.x.x
174 * @param {Document} doc
175 */
176 BlockContextPicker.prototype._injectIframeStyles = function( doc ) {
177 if ( doc.getElementById( 'zipwp-context-picker-styles' ) ) {
178 return;
179 }
180
181 const style = doc.createElement( 'style' );
182 style.id = 'zipwp-context-picker-styles';
183 style.textContent =
184 '.zipwp-context-pin{display:none;position:absolute;z-index:100000;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:none;border-radius:6px;background:#6366f1;color:#fff;cursor:pointer;box-shadow:0 2px 8px rgba(99,102,241,.35);transition:background-color .15s ease,transform .15s ease,box-shadow .15s ease}' +
185 '.zipwp-context-pin:hover{background:#4f46e5;transform:scale(1.1);box-shadow:0 4px 12px rgba(99,102,241,.45)}' +
186 '.zipwp-context-pin svg{width:16px;height:16px;display:block;pointer-events:none}' +
187 '.zipwp-context-pin--active{background:#16a34a}' +
188 '.zipwp-context-pin--active:hover{background:#dc2626}' +
189 '.zipwp-context-selected{box-shadow:0 0 0 2px #6366f1 !important;border-radius:2px}';
190 doc.head.appendChild( style );
191 };
192
193 /**
194 * Remove the pin button from DOM.
195 *
196 * @since x.x.x
197 */
198 BlockContextPicker.prototype._removePinButton = function() {
199 if ( this.pinButton ) {
200 this.pinButton.removeEventListener( 'click', this._onPinClick );
201 this.pinButton.remove();
202 this.pinButton = null;
203 }
204 };
205
206 /**
207 * Position the pin button next to the hovered block.
208 *
209 * @since x.x.x
210 * @param {HTMLElement} blockEl
211 */
212 BlockContextPicker.prototype._positionPinButton = function( blockEl ) {
213 if ( ! this.pinButton ) {
214 return;
215 }
216
217 const doc = this._editorDocument;
218 const rect = blockEl.getBoundingClientRect();
219 const scrollTop = doc.defaultView.scrollY || doc.documentElement.scrollTop;
220 const scrollLeft = doc.defaultView.scrollX || doc.documentElement.scrollLeft;
221
222 this.pinButton.style.top = ( rect.top + scrollTop + 4 ) + 'px';
223 this.pinButton.style.left = ( rect.right + scrollLeft - 36 ) + 'px';
224 this.pinButton.style.display = 'flex';
225
226 // Update icon for pinned state
227 const clientId = blockEl.dataset.block;
228 if ( clientId && this.contextBlocks.has( clientId ) ) {
229 this.pinButton.classList.add( 'zipwp-context-pin--active' );
230 this.pinButton.innerHTML = ICON_CHECK;
231 this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' );
232 } else {
233 this.pinButton.classList.remove( 'zipwp-context-pin--active' );
234 this.pinButton.innerHTML = ICON_ADD;
235 this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' );
236 }
237 };
238
239 /**
240 * Hide the pin button.
241 *
242 * @since x.x.x
243 */
244 BlockContextPicker.prototype._hidePinButton = function() {
245 if ( this.pinButton ) {
246 this.pinButton.style.display = 'none';
247 }
248 };
249
250 // ── Events — hover delegation ───────────────────────────────────
251
252 /**
253 * Bind mouseover/mouseout on the editor canvas via delegation.
254 *
255 * @since x.x.x
256 */
257 BlockContextPicker.prototype._bindEditorEvents = function() {
258 const doc = this._editorDocument;
259 doc.addEventListener( 'mouseover', this._onMouseOver, true );
260 doc.addEventListener( 'mouseout', this._onMouseOut, true );
261 };
262
263 /**
264 * Unbind editor hover events.
265 *
266 * @since x.x.x
267 */
268 BlockContextPicker.prototype._unbindEditorEvents = function() {
269 const doc = this._editorDocument;
270 doc.removeEventListener( 'mouseover', this._onMouseOver, true );
271 doc.removeEventListener( 'mouseout', this._onMouseOut, true );
272 };
273
274 /**
275 * Handle mouseover — find the closest block wrapper.
276 *
277 * @since x.x.x
278 * @param {MouseEvent} e
279 */
280 BlockContextPicker.prototype._handleMouseOver = function( e ) {
281 if ( ! this.active ) {
282 return;
283 }
284
285 if ( this.pinButton && this.pinButton.contains( e.target ) ) {
286 return;
287 }
288
289 const blockEl = e.target.closest( '[data-block]' );
290 if ( ! blockEl ) {
291 return;
292 }
293
294 const clientId = blockEl.dataset.block;
295 if ( ! clientId || clientId === this.hoveredClientId ) {
296 return;
297 }
298
299 this.hoveredClientId = clientId;
300 this._positionPinButton( blockEl );
301 };
302
303 /**
304 * Handle mouseout — hide pin when cursor leaves all blocks.
305 *
306 * @since x.x.x
307 * @param {MouseEvent} e
308 */
309 BlockContextPicker.prototype._handleMouseOut = function( e ) {
310 if ( ! this.active ) {
311 return;
312 }
313
314 if ( this.pinButton && this.pinButton.contains( e.relatedTarget ) ) {
315 return;
316 }
317
318 const relatedBlock = e.relatedTarget && e.relatedTarget.closest
319 ? e.relatedTarget.closest( '[data-block]' )
320 : null;
321 if ( relatedBlock ) {
322 return;
323 }
324
325 this.hoveredClientId = null;
326 this._hidePinButton();
327 };
328
329 /**
330 * Handle click on the pin button — toggle block in context.
331 *
332 * @since x.x.x
333 * @param {MouseEvent} e
334 */
335 BlockContextPicker.prototype._handlePinClick = function( e ) {
336 e.preventDefault();
337 e.stopPropagation();
338
339 const clientId = this.hoveredClientId;
340 if ( ! clientId ) {
341 return;
342 }
343
344 if ( this.contextBlocks.has( clientId ) ) {
345 // Unpin
346 this.contextBlocks.delete( clientId );
347 this._removeHighlight( clientId );
348 if ( this.pinButton ) {
349 this.pinButton.classList.remove( 'zipwp-context-pin--active' );
350 this.pinButton.innerHTML = ICON_ADD;
351 this.pinButton.setAttribute( 'aria-label', 'Pin block to chat context' );
352 }
353 } else {
354 // Pin
355 const blockData = this._serializeBlockForContext( clientId );
356 if ( blockData ) {
357 this.contextBlocks.set( clientId, blockData );
358 this._addHighlight( clientId );
359 if ( this.pinButton ) {
360 this.pinButton.classList.add( 'zipwp-context-pin--active' );
361 this.pinButton.innerHTML = ICON_CHECK;
362 this.pinButton.setAttribute( 'aria-label', 'Unpin block from chat context' );
363 }
364 }
365 }
366
367 this._updateBadge();
368 this._notifyContextChanged();
369 };
370
371 // ── Bridge notification ─────────────────────────────────────────
372
373 /**
374 * Notify the React app that context blocks changed.
375 * Emits event on the React DirectBridge so components can react.
376 *
377 * @since x.x.x
378 */
379 BlockContextPicker.prototype._notifyContextChanged = function() {
380 const appBridge = window.zipwpMcpAppBridge;
381 if ( appBridge && appBridge.emit ) {
382 appBridge.emit( 'context_blocks_changed', {
383 context_blocks: this.getContextBlocks(),
384 count: this.contextBlocks.size,
385 } );
386 }
387 };
388
389 // ── Block serialization ─────────────────────────────────────────
390
391 /**
392 * Serialize a block for chat context.
393 *
394 * @since x.x.x
395 * @param {string} clientId
396 * @return {Object|null}
397 */
398 BlockContextPicker.prototype._serializeBlockForContext = function( clientId ) {
399 if ( ! window.wp || ! window.wp.data || ! window.wp.blocks ) {
400 return null;
401 }
402
403 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
404 const block = blockEditorSelect.getBlock( clientId );
405 if ( ! block ) {
406 return null;
407 }
408
409 let blocksHtml = '';
410 try {
411 blocksHtml = window.wp.blocks.serialize( [ block ] );
412 const bridge = window.zipwpMcpBridge;
413 if ( bridge && bridge.fixUnicodeEscapes ) {
414 blocksHtml = bridge.fixUnicodeEscapes( blocksHtml );
415 }
416 } catch ( e ) {
417 console.warn( 'BlockContextPicker: Failed to serialize block', e ); // eslint-disable-line no-console -- intentional error surfacing
418 }
419
420 const rootClientId = blockEditorSelect.getBlockRootClientId( clientId );
421 const blockIndex = blockEditorSelect.getBlockIndex( clientId );
422
423 return {
424 clientId: block.clientId,
425 name: block.name,
426 blocks_html: blocksHtml,
427 block_index: blockIndex,
428 root_client_id: rootClientId || null,
429 };
430 };
431
432 // ── Visual feedback — block highlights ──────────────────────────
433
434 /**
435 * @param clientId
436 * @since x.x.x
437 */
438 BlockContextPicker.prototype._addHighlight = function( clientId ) {
439 const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' );
440 if ( blockEl ) {
441 blockEl.classList.add( 'zipwp-context-selected' );
442 }
443 };
444
445 /**
446 * @param clientId
447 * @since x.x.x
448 */
449 BlockContextPicker.prototype._removeHighlight = function( clientId ) {
450 const blockEl = this._editorDocument.querySelector( '[data-block="' + clientId + '"]' );
451 if ( blockEl ) {
452 blockEl.classList.remove( 'zipwp-context-selected' );
453 }
454 };
455
456 /**
457 * @since x.x.x
458 */
459 BlockContextPicker.prototype._removeAllHighlights = function() {
460 const els = this._editorDocument.querySelectorAll( '.zipwp-context-selected' );
461 for ( let i = 0; i < els.length; i++ ) {
462 els[ i ].classList.remove( 'zipwp-context-selected' );
463 }
464 };
465
466 // ── Badge on FAB trigger button ─────────────────────────────────
467
468 /**
469 * @since x.x.x
470 */
471 BlockContextPicker.prototype._updateBadge = function() {
472 const trigger = document.getElementById( 'zip-ai-floating-trigger' );
473 if ( ! trigger ) {
474 return;
475 }
476
477 let badge = trigger.querySelector( '.zipwp-context-badge' );
478 const count = this.contextBlocks.size;
479
480 if ( count === 0 ) {
481 if ( badge ) {
482 badge.remove();
483 }
484 return;
485 }
486
487 if ( ! badge ) {
488 badge = document.createElement( 'span' );
489 badge.className = 'zipwp-context-badge';
490 trigger.appendChild( badge );
491 }
492
493 badge.textContent = count;
494 };
495
496 // ── Block deletion watcher ──────────────────────────────────────
497
498 /**
499 * @since x.x.x
500 */
501 BlockContextPicker.prototype._watchBlockDeletion = function() {
502 if ( ! window.wp || ! window.wp.data ) {
503 return;
504 }
505
506 const self = this;
507 const subscribe = window.wp.data.subscribe;
508 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
509
510 this._lastBlockCount = blockEditorSelect.getGlobalBlockCount
511 ? blockEditorSelect.getGlobalBlockCount()
512 : blockEditorSelect.getBlocks().length;
513
514 this._unsubscribeBlockDeletion = subscribe( function() {
515 if ( self.contextBlocks.size === 0 ) {
516 return;
517 }
518
519 const currentCount = blockEditorSelect.getGlobalBlockCount
520 ? blockEditorSelect.getGlobalBlockCount()
521 : blockEditorSelect.getBlocks().length;
522
523 if ( currentCount >= self._lastBlockCount ) {
524 self._lastBlockCount = currentCount;
525 return;
526 }
527
528 self._lastBlockCount = currentCount;
529
530 const currentBlockIds = self._getAllBlockClientIds();
531 let changed = false;
532
533 // Use Array.from to avoid iterator issues in older environments
534 const pinnedIds = Array.from( self.contextBlocks.keys() );
535 for ( let i = 0; i < pinnedIds.length; i++ ) {
536 if ( ! currentBlockIds.has( pinnedIds[ i ] ) ) {
537 self.contextBlocks.delete( pinnedIds[ i ] );
538 self._removeHighlight( pinnedIds[ i ] );
539 changed = true;
540 }
541 }
542
543 if ( changed ) {
544 self._updateBadge();
545 self._notifyContextChanged();
546 }
547 } );
548 };
549
550 /**
551 * @since x.x.x
552 */
553 BlockContextPicker.prototype._unwatchBlockDeletion = function() {
554 if ( this._unsubscribeBlockDeletion ) {
555 this._unsubscribeBlockDeletion();
556 this._unsubscribeBlockDeletion = null;
557 }
558 };
559
560 /**
561 * Get a Set of all block clientIds in the editor.
562 *
563 * @since x.x.x
564 * @return {Set<string>}
565 */
566 BlockContextPicker.prototype._getAllBlockClientIds = function() {
567 const ids = new Set();
568 if ( ! window.wp || ! window.wp.data ) {
569 return ids;
570 }
571
572 const blockEditorSelect = window.wp.data.select( 'core/block-editor' );
573 if ( ! blockEditorSelect ) {
574 return ids;
575 }
576
577 const collectIds = function( blocks ) {
578 for ( let i = 0; i < blocks.length; i++ ) {
579 ids.add( blocks[ i ].clientId );
580 if ( blocks[ i ].innerBlocks && blocks[ i ].innerBlocks.length > 0 ) {
581 collectIds( blocks[ i ].innerBlocks );
582 }
583 }
584 };
585
586 collectIds( blockEditorSelect.getBlocks() );
587 return ids;
588 };
589
590 // Expose singleton on window
591 window.zipwpMcpContextPicker = new BlockContextPicker();
592 }() );
593