PluginProbe
Extendify / 3.1.1
Extendify v3.1.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / unit / QuickEdit / lib / hover-bar.test.js

hover-bar.test.js in Extendify 3.1.1, at tests/unit/QuickEdit/lib/hover-bar.test.js

1,662 lines 53.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { isAgentEligibleForTarget } from '@quick-edit/lib/agent-gate';
2
3 const TAGGED_SEL =
4 '[data-extendify-agent-block-id], [data-extendify-part-block-id], .wp-block-navigation';
5
6 const makeTarget = (el, source = { kind: 'post', id: 1 }) => ({
7 el,
8 blockType: 'core/group',
9 source,
10 });
11
12 const makeEl = ({ classes = [], innerTagged = 0 } = {}) => {
13 const el = document.createElement('div');
14 for (const c of classes) el.classList.add(c);
15 for (let i = 0; i < innerTagged; i++) {
16 const child = document.createElement('div');
17 child.setAttribute('data-extendify-agent-block-id', String(i + 1));
18 el.appendChild(child);
19 }
20 return el;
21 };
22
23 describe('isAgentEligibleForTarget', () => {
24 it('rejects spacer / video / post-* targets so Ask AI does not surface', () => {
25 expect(
26 isAgentEligibleForTarget(
27 makeTarget(makeEl({ classes: ['wp-block-spacer'] })),
28 ),
29 ).toBe(false);
30 expect(
31 isAgentEligibleForTarget(
32 makeTarget(makeEl({ classes: ['wp-block-video'] })),
33 ),
34 ).toBe(false);
35 expect(
36 isAgentEligibleForTarget(
37 makeTarget(makeEl({ classes: ['wp-block-post-title'] })),
38 ),
39 ).toBe(false);
40 expect(
41 isAgentEligibleForTarget(
42 makeTarget(makeEl({ classes: ['wp-block-post-author'] })),
43 ),
44 ).toBe(false);
45 });
46
47 it('rejects targets with more than 50 tagged inner blocks', () => {
48 const el = makeEl({ innerTagged: 51 });
49 expect(el.querySelectorAll(TAGGED_SEL).length).toBe(51);
50 expect(isAgentEligibleForTarget(makeTarget(el))).toBe(false);
51 });
52
53 it('accepts a normal tagged paragraph', () => {
54 const p = makeEl({ classes: ['wp-block-paragraph'] });
55 expect(isAgentEligibleForTarget(makeTarget(p))).toBe(true);
56 });
57
58 it('accepts a tagged container with exactly 50 tagged inner blocks', () => {
59 const el = makeEl({ innerTagged: 50 });
60 expect(isAgentEligibleForTarget(makeTarget(el))).toBe(true);
61 });
62 });
63
64 // Drives showBar through real DOM fixtures to pin the new pill-render
65 // contract: selection lands on the innermost tagged ancestor regardless
66 // of its class (no more "promote a recognized child" preference), and
67 // the Quick Edit pill only renders when the resolved blockType has a
68 // modal handler. With window.extAgentData set the Ask AI pill renders
69 // alongside; without it, Ask-AI-only blocks render no bar at all.
70 jest.mock('@quick-edit/lib/insights', () => ({ track: jest.fn() }));
71 jest.mock('@quick-edit/lib/block-source-cache', () => ({
72 prefetchBlockSource: jest.fn(),
73 getBlockSource: jest.fn(),
74 invalidateBlockSource: jest.fn(),
75 }));
76
77 const BAR_SEL = '.extendify-quick-edit-bar';
78 const QE_PILL_SEL =
79 '.extendify-quick-edit-pill:not(.extendify-quick-edit-pill-ai)';
80 const AI_PILL_SEL = '.extendify-quick-edit-pill-ai';
81
82 const tagged = (el, blockId) => {
83 el.setAttribute('data-extendify-agent-block-id', String(blockId));
84 return el;
85 };
86
87 const div = (classes = []) => {
88 const el = document.createElement('div');
89 for (const c of classes) el.classList.add(c);
90 return el;
91 };
92
93 const tag = (tagName, classes = []) => {
94 const el = document.createElement(tagName);
95 for (const c of classes) el.classList.add(c);
96 return el;
97 };
98
99 describe('hover bar: pill rendering by resolved blockType', () => {
100 let showBar;
101 let useQuickEditStore;
102
103 beforeEach(() => {
104 jest.resetModules();
105 document.body.innerHTML = '';
106 delete window.extAgentData;
107 // QE enabled is the precondition these behavior tests assume; the
108 // showQuickEdit gate is exercised in its own describe below.
109 window.extQuickEditData = { quickEditEnabled: true };
110 ({ showBar } = require('@quick-edit/lib/hover-bar'));
111 ({ useQuickEditStore } = require('@quick-edit/state/store'));
112 useQuickEditStore.getState().clearSelected();
113 });
114
115 afterEach(() => {
116 document.body.innerHTML = '';
117 useQuickEditStore.getState().clearSelected();
118 });
119
120 const clickQuickEdit = () => {
121 const pill = document.querySelector(QE_PILL_SEL);
122 if (!pill) return null;
123 pill.dispatchEvent(new MouseEvent('click', { bubbles: true }));
124 return useQuickEditStore.getState().selected;
125 };
126
127 // The Quick Edit pill is gated on the showQuickEdit partner
128 // flag, surfaced to JS as window.extQuickEditData.quickEditEnabled.
129 // When QE is off the QE pill never renders, but Ask AI is independent
130 // and keeps surfacing (the agent's selector is unaffected).
131 describe('Quick Edit gate (quickEditEnabled)', () => {
132 const paragraphTarget = (el) => ({
133 el,
134 blockType: 'core/paragraph',
135 source: { kind: 'post', id: 1 },
136 });
137
138 it('quickEditEnabled false → quickEditable false, aiAvailable still true', () => {
139 window.extAgentData = {};
140 window.extQuickEditData = { quickEditEnabled: false };
141 const { pillContextFor } = require('@quick-edit/lib/hover-bar');
142
143 const { quickEditable, aiAvailable } = pillContextFor(
144 paragraphTarget(tag('p', ['wp-block-paragraph'])),
145 );
146
147 expect(quickEditable).toBe(false);
148 expect(aiAvailable).toBe(true);
149 });
150
151 it('quickEditEnabled true → quickEditable true for a modal-backed block', () => {
152 window.extQuickEditData = { quickEditEnabled: true };
153 const { pillContextFor } = require('@quick-edit/lib/hover-bar');
154
155 expect(
156 pillContextFor(paragraphTarget(tag('p', ['wp-block-paragraph'])))
157 .quickEditable,
158 ).toBe(true);
159 });
160
161 it('missing extQuickEditData → quickEditable false', () => {
162 window.extAgentData = {};
163 delete window.extQuickEditData;
164 const { pillContextFor } = require('@quick-edit/lib/hover-bar');
165
166 const { quickEditable, aiAvailable } = pillContextFor(
167 paragraphTarget(tag('p', ['wp-block-paragraph'])),
168 );
169
170 expect(quickEditable).toBe(false);
171 expect(aiAvailable).toBe(true);
172 });
173
174 it('QE off + no agent → showBar renders no bar at all on a paragraph', () => {
175 window.extQuickEditData = { quickEditEnabled: false };
176 const p = tagged(tag('p', ['wp-block-paragraph']), 5);
177 document.body.appendChild(p);
178
179 showBar(p);
180
181 expect(document.querySelector(QE_PILL_SEL)).toBeNull();
182 expect(document.querySelector(BAR_SEL)).toBeNull();
183 });
184
185 it('QE off + agent available → showBar renders only the Ask AI pill', () => {
186 window.extAgentData = {};
187 window.extQuickEditData = { quickEditEnabled: false };
188 const p = tagged(tag('p', ['wp-block-paragraph']), 5);
189 document.body.appendChild(p);
190
191 showBar(p);
192
193 expect(document.querySelector(QE_PILL_SEL)).toBeNull();
194 expect(document.querySelector(AI_PILL_SEL)).not.toBeNull();
195 });
196 });
197
198 it('renders a Quick Edit pill on a tagged paragraph (paragraph has a modal handler)', () => {
199 const p = tagged(tag('p', ['wp-block-paragraph']), 5);
200 document.body.appendChild(p);
201
202 showBar(p);
203
204 expect(document.querySelector(BAR_SEL)).not.toBeNull();
205 const selected = clickQuickEdit();
206 expect(selected.el).toBe(p);
207 expect(selected.blockType).toBe('core/paragraph');
208 expect(selected.blockId).toBe(5);
209 });
210
211 it('renders a Quick Edit pill on a tagged heading', () => {
212 const h = tagged(tag('h2', ['wp-block-heading']), 9);
213 document.body.appendChild(h);
214
215 showBar(h);
216
217 const selected = clickQuickEdit();
218 expect(selected.blockType).toBe('core/heading');
219 });
220
221 it('selection of a child resolves to the tagged ancestor (no recognition preference)', () => {
222 // Hovering a cover inside a tagged group now selects the group —
223 // the container is what's tagged. core/group has no QE handler, so
224 // no Quick Edit pill renders. With Ask AI unavailable in this test,
225 // the bar doesn't mount at all.
226 const group = tagged(div(['wp-block-group']), 7);
227 const cover = div(['wp-block-cover']);
228 group.appendChild(cover);
229 document.body.appendChild(group);
230
231 showBar(cover);
232
233 expect(document.querySelector(QE_PILL_SEL)).toBeNull();
234 expect(document.querySelector(BAR_SEL)).toBeNull();
235 });
236
237 it('renders an Ask AI pill (no Quick Edit) on a tagged container when the agent is available', () => {
238 window.extAgentData = {};
239 const group = tagged(div(['wp-block-group']), 7);
240 document.body.appendChild(group);
241
242 showBar(group);
243
244 expect(document.querySelector(BAR_SEL)).not.toBeNull();
245 expect(document.querySelector(QE_PILL_SEL)).toBeNull();
246 expect(document.querySelector(AI_PILL_SEL)).not.toBeNull();
247 });
248
249 it.each([
250 'wp-block-media-text',
251 'wp-block-columns',
252 'wp-block-gallery',
253 'wp-block-quote',
254 'wp-block-list',
255 'wp-block-table',
256 'wp-block-buttons',
257 ])('renders Ask AI but no Quick Edit on tagged %s', (cls) => {
258 window.extAgentData = {};
259 const el = tagged(div([cls]), 4);
260 document.body.appendChild(el);
261
262 showBar(el);
263
264 expect(document.querySelector(BAR_SEL)).not.toBeNull();
265 expect(document.querySelector(QE_PILL_SEL)).toBeNull();
266 expect(document.querySelector(AI_PILL_SEL)).not.toBeNull();
267 });
268
269 // Soft-selection contract — see hover-bar.js onMouseOver + onDocClickCapture.
270 // When a block is staged for Ask AI, hovers on OTHER blocks are
271 // suppressed; the staged block itself still renders the bar; clicks
272 // outside clear the staged block without touching the sidebar.
273 describe('soft selection while an agent block is staged', () => {
274 const mockSetOpen = jest.fn();
275
276 beforeEach(() => {
277 window.extAgentData = {};
278 jest.doMock('@agent/state/global', () => ({
279 useGlobalStore: {
280 getState: () => ({ setOpen: mockSetOpen }),
281 subscribe: () => () => {},
282 },
283 }));
284 });
285
286 afterEach(() => {
287 jest.dontMock('@agent/state/global');
288 delete window.extAgentData;
289 mockSetOpen.mockReset();
290 });
291
292 it('outside-click clears agentBlock but does NOT close the sidebar', () => {
293 jest.resetModules();
294 const hoverBar = require('@quick-edit/lib/hover-bar');
295 const {
296 useQuickEditStore: storeForThisRun,
297 } = require('@quick-edit/state/store');
298 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
299 useEditModeStore.setState({ on: true });
300
301 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
302 document.body.appendChild(staged);
303
304 const outside = document.createElement('div');
305 document.body.appendChild(outside);
306
307 storeForThisRun.setState({
308 agentBlock: {
309 id: 'b-1',
310 target: 'data-extendify-agent-block-id',
311 },
312 agentBlockCode: null,
313 });
314
315 hoverBar.attach();
316
317 outside.dispatchEvent(
318 new MouseEvent('click', { bubbles: true, cancelable: true }),
319 );
320
321 expect(storeForThisRun.getState().agentBlock).toBeNull();
322 expect(mockSetOpen).not.toHaveBeenCalled();
323
324 hoverBar.detach();
325 });
326
327 it('click inside the staged block does NOT clear it', () => {
328 jest.resetModules();
329 const hoverBar = require('@quick-edit/lib/hover-bar');
330 const {
331 useQuickEditStore: storeForThisRun,
332 } = require('@quick-edit/state/store');
333 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
334 useEditModeStore.setState({ on: true });
335
336 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
337 const child = document.createElement('span');
338 staged.appendChild(child);
339 document.body.appendChild(staged);
340
341 storeForThisRun.setState({
342 agentBlock: {
343 id: 'b-1',
344 target: 'data-extendify-agent-block-id',
345 },
346 agentBlockCode: null,
347 });
348
349 hoverBar.attach();
350
351 child.dispatchEvent(
352 new MouseEvent('click', { bubbles: true, cancelable: true }),
353 );
354
355 expect(storeForThisRun.getState().agentBlock).not.toBeNull();
356
357 hoverBar.detach();
358 });
359
360 it('click inside an open wp.media modal does NOT clear the staged block', () => {
361 jest.resetModules();
362 const hoverBar = require('@quick-edit/lib/hover-bar');
363 const {
364 useQuickEditStore: storeForThisRun,
365 } = require('@quick-edit/state/store');
366 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
367 useEditModeStore.setState({ on: true });
368
369 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
370 document.body.appendChild(staged);
371
372 // The Agent's "Change image" picker opens a wp.media modal. Clicking
373 // an image inside it must not read as an outside-click — that would
374 // clear the staged block, cancel the in-flight workflow, and orphan
375 // the modal as a stuck white overlay.
376 const modal = tag('div', ['media-modal']);
377 const attachment = document.createElement('button');
378 modal.appendChild(attachment);
379 document.body.appendChild(modal);
380
381 storeForThisRun.setState({
382 agentBlock: { id: 'b-1', target: 'data-extendify-agent-block-id' },
383 agentBlockCode: null,
384 });
385
386 hoverBar.attach();
387
388 attachment.dispatchEvent(
389 new MouseEvent('click', { bubbles: true, cancelable: true }),
390 );
391
392 expect(storeForThisRun.getState().agentBlock).not.toBeNull();
393
394 hoverBar.detach();
395 });
396
397 it('hovering ANOTHER tagged block while staged does not render the bar', () => {
398 jest.resetModules();
399 const hoverBar = require('@quick-edit/lib/hover-bar');
400 const {
401 useQuickEditStore: storeForThisRun,
402 } = require('@quick-edit/state/store');
403 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
404 useEditModeStore.setState({ on: true });
405
406 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
407 const other = tagged(tag('p', ['wp-block-paragraph']), 'b-2');
408 document.body.append(staged, other);
409
410 storeForThisRun.setState({
411 agentBlock: {
412 id: 'b-1',
413 target: 'data-extendify-agent-block-id',
414 },
415 agentBlockCode: null,
416 });
417
418 hoverBar.attach();
419
420 other.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
421
422 expect(document.querySelector(BAR_SEL)).toBeNull();
423
424 hoverBar.detach();
425 });
426
427 it('hovering the staged block itself does NOT render the bar (no pills while staged)', () => {
428 // Per the updated contract: while agentBlock is staged, the
429 // hover bar is intentionally hidden — only DOMHighlighter's
430 // X-close shows. To re-engage Ask AI, the user clicks X-close
431 // (clearing agentBlock) and re-hovers.
432 jest.resetModules();
433 const hoverBar = require('@quick-edit/lib/hover-bar');
434 const {
435 useQuickEditStore: storeForThisRun,
436 } = require('@quick-edit/state/store');
437 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
438 useEditModeStore.setState({ on: true });
439
440 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
441 document.body.appendChild(staged);
442
443 storeForThisRun.setState({
444 agentBlock: {
445 id: 'b-1',
446 target: 'data-extendify-agent-block-id',
447 },
448 agentBlockCode: null,
449 });
450
451 hoverBar.attach();
452
453 staged.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
454
455 expect(document.querySelector(BAR_SEL)).toBeNull();
456
457 hoverBar.detach();
458 });
459
460 // Clicking a DIFFERENT tagged block while
461 // agent is staged transitions both surfaces in the same gesture:
462 // clear the old stage and run the standard click table against
463 // the new block. For a two-pill block with the sidebar open
464 // that opens QE on the new block AND re-stages agentBlock.
465 // Outside-clicks on whitespace still clear without falling
466 // through (no new selection is created from empty space).
467 it('clicking a different tagged block (two-pill) with agent open transitions selected + agentBlock', async () => {
468 window.extAgentData = {};
469 jest.doMock('@agent/state/global', () => ({
470 useGlobalStore: {
471 getState: () => ({ open: true, setOpen: jest.fn() }),
472 subscribe: () => () => {},
473 },
474 }));
475
476 jest.resetModules();
477 const hoverBar = require('@quick-edit/lib/hover-bar');
478 const {
479 useQuickEditStore: storeForThisRun,
480 } = require('@quick-edit/state/store');
481 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
482 useEditModeStore.setState({ on: true });
483
484 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
485 const other = tagged(tag('p', ['wp-block-paragraph']), 'b-2');
486 document.body.append(staged, other);
487
488 storeForThisRun.setState({
489 agentBlock: {
490 id: 'b-1',
491 target: 'data-extendify-agent-block-id',
492 },
493 agentBlockCode: null,
494 });
495
496 hoverBar.attach();
497 await new Promise((resolve) => setTimeout(resolve, 0));
498
499 other.dispatchEvent(
500 new MouseEvent('click', { bubbles: true, cancelable: true }),
501 );
502
503 expect(storeForThisRun.getState().selected).not.toBeNull();
504 expect(storeForThisRun.getState().selected.el).toBe(other);
505 expect(storeForThisRun.getState().agentBlock).not.toBeNull();
506 expect(storeForThisRun.getState().agentBlock.id).toBe('b-2');
507
508 hoverBar.detach();
509 jest.dontMock('@agent/state/global');
510 });
511
512 it('clicking a different Ask-AI-only tagged block with agent open transitions agentBlock', async () => {
513 window.extAgentData = {};
514 jest.doMock('@agent/state/global', () => ({
515 useGlobalStore: {
516 getState: () => ({ open: true, setOpen: jest.fn() }),
517 subscribe: () => () => {},
518 },
519 }));
520
521 jest.resetModules();
522 const hoverBar = require('@quick-edit/lib/hover-bar');
523 const {
524 useQuickEditStore: storeForThisRun,
525 } = require('@quick-edit/state/store');
526 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
527 useEditModeStore.setState({ on: true });
528
529 const staged = tagged(div(['wp-block-group']), 'g-1');
530 const other = tagged(div(['wp-block-group']), 'g-2');
531 document.body.append(staged, other);
532
533 storeForThisRun.setState({
534 agentBlock: {
535 id: 'g-1',
536 target: 'data-extendify-agent-block-id',
537 },
538 agentBlockCode: null,
539 });
540
541 hoverBar.attach();
542 await new Promise((resolve) => setTimeout(resolve, 0));
543
544 other.dispatchEvent(
545 new MouseEvent('click', { bubbles: true, cancelable: true }),
546 );
547
548 expect(storeForThisRun.getState().agentBlock).not.toBeNull();
549 expect(storeForThisRun.getState().agentBlock.id).toBe('g-2');
550 expect(storeForThisRun.getState().selected).toBeNull();
551
552 hoverBar.detach();
553 jest.dontMock('@agent/state/global');
554 });
555
556 it('clicking a tagged descendant inside the staged block re-stages on the descendant', async () => {
557 window.extAgentData = {};
558 jest.doMock('@agent/state/global', () => ({
559 useGlobalStore: {
560 getState: () => ({ open: true, setOpen: jest.fn() }),
561 subscribe: () => () => {},
562 },
563 }));
564
565 jest.resetModules();
566 const hoverBar = require('@quick-edit/lib/hover-bar');
567 const {
568 useQuickEditStore: storeForThisRun,
569 } = require('@quick-edit/state/store');
570 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
571 useEditModeStore.setState({ on: true });
572
573 const staged = tagged(div(['wp-block-group']), 'g-1');
574 const inner = tagged(div(['wp-block-group']), 'g-2');
575 staged.appendChild(inner);
576 document.body.appendChild(staged);
577
578 storeForThisRun.setState({
579 agentBlock: {
580 id: 'g-1',
581 target: 'data-extendify-agent-block-id',
582 },
583 agentBlockCode: null,
584 });
585
586 hoverBar.attach();
587 await new Promise((resolve) => setTimeout(resolve, 0));
588
589 inner.dispatchEvent(
590 new MouseEvent('click', { bubbles: true, cancelable: true }),
591 );
592
593 expect(storeForThisRun.getState().agentBlock).not.toBeNull();
594 expect(storeForThisRun.getState().agentBlock.id).toBe('g-2');
595
596 hoverBar.detach();
597 jest.dontMock('@agent/state/global');
598 });
599
600 it('clicking whitespace while staged clears agentBlock and does NOT commit a new selection', () => {
601 jest.resetModules();
602 const hoverBar = require('@quick-edit/lib/hover-bar');
603 const {
604 useQuickEditStore: storeForThisRun,
605 } = require('@quick-edit/state/store');
606 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
607 useEditModeStore.setState({ on: true });
608
609 const staged = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
610 const outside = document.createElement('div');
611 document.body.append(staged, outside);
612
613 storeForThisRun.setState({
614 agentBlock: {
615 id: 'b-1',
616 target: 'data-extendify-agent-block-id',
617 },
618 agentBlockCode: null,
619 });
620
621 hoverBar.attach();
622
623 outside.dispatchEvent(
624 new MouseEvent('click', { bubbles: true, cancelable: true }),
625 );
626
627 expect(storeForThisRun.getState().agentBlock).toBeNull();
628 expect(storeForThisRun.getState().selected).toBeNull();
629 expect(document.querySelector(BAR_SEL)).toBeNull();
630
631 hoverBar.detach();
632 });
633 });
634
635 // Click-to-commit contract — see hover-bar.js onDocClickCapture +
636 // onMouseOver. A click on a tagged block pins the bar/outline to it;
637 // hover on other tagged blocks is suppressed; a click on a different
638 // tagged block commits the new one in the same gesture (single-click
639 // swap); a click outside any tagged block clears the commit.
640 //
641 // Commit survives only for Ask-AI-only blocks
642 // with the agent sidebar closed. Two-pill (QE + Ask AI) and QE-only
643 // blocks open QE directly on click — see the Option 7 click table
644 // describe block below. These tests use core/group fixtures, which
645 // have no QE modal handler and become Ask-AI-only when extAgentData is
646 // set; the dynamic import for @agent/state/global resolves to its
647 // initial open=false state in the click rule's sidebar cache.
648 describe('click-to-commit', () => {
649 const dispatchClick = (el) => {
650 el.dispatchEvent(
651 new MouseEvent('click', { bubbles: true, cancelable: true }),
652 );
653 };
654
655 const setEditModeOn = () => {
656 jest.resetModules();
657 const hoverBar = require('@quick-edit/lib/hover-bar');
658 const { useQuickEditStore: store } = require('@quick-edit/state/store');
659 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
660 useEditModeStore.setState({ on: true });
661 return { hoverBar, store };
662 };
663
664 beforeEach(() => {
665 window.extAgentData = {};
666 });
667
668 afterEach(() => {
669 document.body.innerHTML = '';
670 delete window.extAgentData;
671 });
672
673 it('clicking an Ask-AI-only tagged block sets committedSelection and renders the bar', () => {
674 const { hoverBar, store } = setEditModeOn();
675 const g = tagged(div(['wp-block-group']), 5);
676 document.body.appendChild(g);
677 hoverBar.attach();
678
679 dispatchClick(g);
680
681 expect(document.querySelector(BAR_SEL)).not.toBeNull();
682 expect(store.getState().committedSelection).not.toBeNull();
683 expect(store.getState().committedSelection.el).toBe(g);
684
685 hoverBar.detach();
686 });
687
688 it('while committed, hovering a different tagged block does NOT re-render the bar', () => {
689 const { hoverBar, store } = setEditModeOn();
690 const a = tagged(div(['wp-block-group']), 1);
691 const b = tagged(div(['wp-block-group']), 2);
692 document.body.append(a, b);
693 hoverBar.attach();
694
695 dispatchClick(a);
696 const barAfterClick = document.querySelector(BAR_SEL);
697 expect(barAfterClick).not.toBeNull();
698
699 b.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
700
701 expect(document.querySelector(BAR_SEL)).toBe(barAfterClick);
702 expect(store.getState().committedSelection.el).toBe(a);
703
704 hoverBar.detach();
705 });
706
707 it('hovering the committed block itself is idempotent (no tear-down + re-render)', () => {
708 const { hoverBar } = setEditModeOn();
709 const g = tagged(div(['wp-block-group']), 7);
710 document.body.appendChild(g);
711 hoverBar.attach();
712
713 dispatchClick(g);
714 const barAfterClick = document.querySelector(BAR_SEL);
715
716 g.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
717
718 expect(document.querySelector(BAR_SEL)).toBe(barAfterClick);
719
720 hoverBar.detach();
721 });
722
723 it('hovering a tagged inner child of a committed group does NOT move the bar', () => {
724 const { hoverBar, store } = setEditModeOn();
725 const group = tagged(div(['wp-block-group']), 100);
726 const inner = tagged(tag('p', ['wp-block-paragraph']), 101);
727 group.appendChild(inner);
728 document.body.appendChild(group);
729 hoverBar.attach();
730
731 dispatchClick(group);
732 const barAfterClick = document.querySelector(BAR_SEL);
733 expect(store.getState().committedSelection.el).toBe(group);
734
735 inner.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
736
737 expect(document.querySelector(BAR_SEL)).toBe(barAfterClick);
738 expect(store.getState().committedSelection.el).toBe(group);
739
740 hoverBar.detach();
741 });
742
743 it('Esc clears committedSelection and removes the bar', () => {
744 const { hoverBar, store } = setEditModeOn();
745 const { wireGlobalEscape } = require('@quick-edit/lib/global-escape');
746 const g = tagged(div(['wp-block-group']), 200);
747 document.body.appendChild(g);
748 hoverBar.attach();
749 const unwireEsc = wireGlobalEscape();
750
751 dispatchClick(g);
752 expect(store.getState().committedSelection).not.toBeNull();
753 expect(document.querySelector(BAR_SEL)).not.toBeNull();
754
755 document.dispatchEvent(
756 new KeyboardEvent('keydown', {
757 key: 'Escape',
758 bubbles: true,
759 cancelable: true,
760 }),
761 );
762
763 expect(store.getState().committedSelection).toBeNull();
764 expect(document.querySelector(BAR_SEL)).toBeNull();
765
766 unwireEsc();
767 hoverBar.detach();
768 });
769
770 it('clicking outside any tagged block clears committedSelection and the bar', () => {
771 const { hoverBar, store } = setEditModeOn();
772 const g = tagged(div(['wp-block-group']), 9);
773 const outside = document.createElement('div');
774 document.body.append(g, outside);
775 hoverBar.attach();
776
777 dispatchClick(g);
778 expect(store.getState().committedSelection).not.toBeNull();
779
780 dispatchClick(outside);
781
782 expect(store.getState().committedSelection).toBeNull();
783 expect(document.querySelector(BAR_SEL)).toBeNull();
784
785 hoverBar.detach();
786 });
787
788 it('clicking a DIFFERENT tagged block while committed commits the new one in one gesture', () => {
789 const { hoverBar, store } = setEditModeOn();
790 const a = tagged(div(['wp-block-group']), 11);
791 const b = tagged(div(['wp-block-group']), 22);
792 document.body.append(a, b);
793 hoverBar.attach();
794
795 dispatchClick(a);
796 expect(store.getState().committedSelection.el).toBe(a);
797
798 dispatchClick(b);
799
800 expect(store.getState().committedSelection.el).toBe(b);
801 expect(document.querySelector(BAR_SEL)).not.toBeNull();
802
803 hoverBar.detach();
804 });
805
806 it('clicking a tagged descendant of the committed block swaps the commit to the descendant', () => {
807 const { hoverBar, store } = setEditModeOn();
808 const group = tagged(div(['wp-block-group']), 44);
809 const inner = tagged(div(['wp-block-group']), 55);
810 group.appendChild(inner);
811 document.body.appendChild(group);
812 hoverBar.attach();
813
814 dispatchClick(group);
815 expect(store.getState().committedSelection.el).toBe(group);
816
817 dispatchClick(inner);
818
819 expect(store.getState().committedSelection.el).toBe(inner);
820
821 hoverBar.detach();
822 });
823
824 it('clicking the SAME committed block again is a no-op', () => {
825 const { hoverBar, store } = setEditModeOn();
826 const g = tagged(div(['wp-block-group']), 33);
827 document.body.appendChild(g);
828 hoverBar.attach();
829
830 dispatchClick(g);
831 const barAfterFirst = document.querySelector(BAR_SEL);
832 const committedAfterFirst = store.getState().committedSelection;
833
834 dispatchClick(g);
835
836 expect(document.querySelector(BAR_SEL)).toBe(barAfterFirst);
837 expect(store.getState().committedSelection).toBe(committedAfterFirst);
838
839 hoverBar.detach();
840 });
841
842 it('clicking the Ask AI pill clears committedSelection', () => {
843 const mockSetOpen = jest.fn();
844 jest.doMock('@agent/state/global', () => ({
845 useGlobalStore: {
846 getState: () => ({ setOpen: mockSetOpen }),
847 subscribe: () => () => {},
848 },
849 }));
850
851 const { hoverBar, store } = setEditModeOn();
852 const g = tagged(div(['wp-block-group']), 55);
853 document.body.appendChild(g);
854 hoverBar.attach();
855
856 dispatchClick(g);
857 expect(store.getState().committedSelection).not.toBeNull();
858
859 const pill = document.querySelector(AI_PILL_SEL);
860 expect(pill).not.toBeNull();
861 dispatchClick(pill);
862
863 expect(store.getState().committedSelection).toBeNull();
864
865 hoverBar.detach();
866 jest.dontMock('@agent/state/global');
867 });
868 });
869
870 // Click semantics by pill-count + agent
871 // sidebar state. The committed-selection cell narrows to Ask-AI-only +
872 // agent closed; QE-only collapses to direct open; Ask-AI-only + agent
873 // open silently stages the agent's block.
874 describe('Option 7 click table', () => {
875 const dispatchClick = (el) => {
876 el.dispatchEvent(
877 new MouseEvent('click', { bubbles: true, cancelable: true }),
878 );
879 };
880
881 const setEditModeOn = () => {
882 jest.resetModules();
883 const hoverBar = require('@quick-edit/lib/hover-bar');
884 const { useQuickEditStore: store } = require('@quick-edit/state/store');
885 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
886 useEditModeStore.setState({ on: true });
887 return { hoverBar, store };
888 };
889
890 afterEach(() => {
891 document.body.innerHTML = '';
892 delete window.extAgentData;
893 });
894
895 it('QE-only block: click opens QE directly without setting committedSelection', () => {
896 // No extAgentData → paragraph is QE-only.
897 const { hoverBar, store } = setEditModeOn();
898 const p = tagged(tag('p', ['wp-block-paragraph']), 100);
899 document.body.appendChild(p);
900 hoverBar.attach();
901
902 dispatchClick(p);
903
904 expect(store.getState().selected).not.toBeNull();
905 expect(store.getState().selected.el).toBe(p);
906 expect(store.getState().committedSelection).toBeNull();
907
908 hoverBar.detach();
909 });
910
911 it('Ask-AI-only + agent closed: click commits (surviving sticky case)', () => {
912 window.extAgentData = {};
913 jest.doMock('@agent/state/global', () => ({
914 useGlobalStore: {
915 getState: () => ({ open: false, setOpen: jest.fn() }),
916 subscribe: () => () => {},
917 },
918 }));
919
920 const { hoverBar, store } = setEditModeOn();
921 const group = tagged(div(['wp-block-group']), 200);
922 document.body.appendChild(group);
923 hoverBar.attach();
924
925 dispatchClick(group);
926
927 expect(store.getState().committedSelection).not.toBeNull();
928 expect(store.getState().committedSelection.el).toBe(group);
929 expect(document.querySelector(BAR_SEL)).not.toBeNull();
930 expect(store.getState().agentBlock).toBeNull();
931
932 hoverBar.detach();
933 jest.dontMock('@agent/state/global');
934 });
935
936 it('Ask-AI-only + agent open: click silently stages agentBlock (no commit, no bar)', async () => {
937 window.extAgentData = {};
938 jest.doMock('@agent/state/global', () => ({
939 useGlobalStore: {
940 getState: () => ({ open: true, setOpen: jest.fn() }),
941 subscribe: () => () => {},
942 },
943 }));
944
945 const { hoverBar, store } = setEditModeOn();
946 const group = tagged(div(['wp-block-group']), 300);
947 document.body.appendChild(group);
948 // Sidebar is open → the chat textarea is already mounted; the
949 // silent stage drops the cursor into it.
950 const textarea = document.createElement('textarea');
951 textarea.id = 'extendify-agent-chat-textarea';
952 const focusSpy = jest.spyOn(textarea, 'focus');
953 document.body.appendChild(textarea);
954 hoverBar.attach();
955 // Let the sidebar-state watcher's dynamic import resolve so the
956 // click rule sees agentOpen=true on the next sync click.
957 await new Promise((resolve) => setTimeout(resolve, 0));
958
959 dispatchClick(group);
960
961 expect(store.getState().agentBlock).not.toBeNull();
962 expect(store.getState().agentBlock.id).toBe('300');
963 expect(store.getState().committedSelection).toBeNull();
964 expect(document.querySelector(BAR_SEL)).toBeNull();
965 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
966
967 hoverBar.detach();
968 jest.dontMock('@agent/state/global');
969 });
970
971 // Two-pill (QE + Ask AI) blocks collapse the click to
972 // "open QE directly" everywhere, with a silent agent-bridge when
973 // the sidebar is open. The Ask AI pill now lives on the QE chrome
974 // (BlockTextEditor's floating-bar-actions), so the user can still
975 // escalate to the agent from inside the editor.
976 it('two-pill + agent closed: click opens QE directly, does NOT commit, does NOT stage agentBlock', () => {
977 window.extAgentData = {};
978 const { hoverBar, store } = setEditModeOn();
979 const p = tagged(tag('p', ['wp-block-paragraph']), 400);
980 document.body.appendChild(p);
981 hoverBar.attach();
982
983 dispatchClick(p);
984
985 expect(store.getState().selected).not.toBeNull();
986 expect(store.getState().selected.el).toBe(p);
987 expect(store.getState().committedSelection).toBeNull();
988 expect(store.getState().agentBlock).toBeNull();
989
990 hoverBar.detach();
991 });
992
993 it('two-pill + agent open: click opens QE AND silently stages agentBlock', async () => {
994 window.extAgentData = {};
995 jest.doMock('@agent/state/global', () => ({
996 useGlobalStore: {
997 getState: () => ({ open: true, setOpen: jest.fn() }),
998 subscribe: () => () => {},
999 },
1000 }));
1001
1002 const { hoverBar, store } = setEditModeOn();
1003 const p = tagged(tag('p', ['wp-block-paragraph']), 500);
1004 document.body.appendChild(p);
1005 const textarea = document.createElement('textarea');
1006 textarea.id = 'extendify-agent-chat-textarea';
1007 const focusSpy = jest.spyOn(textarea, 'focus');
1008 document.body.appendChild(textarea);
1009 hoverBar.attach();
1010 await new Promise((resolve) => setTimeout(resolve, 0));
1011
1012 dispatchClick(p);
1013
1014 expect(store.getState().selected).not.toBeNull();
1015 expect(store.getState().selected.el).toBe(p);
1016 expect(store.getState().agentBlock).not.toBeNull();
1017 expect(store.getState().agentBlock.id).toBe('500');
1018 expect(store.getState().committedSelection).toBeNull();
1019 expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
1020
1021 hoverBar.detach();
1022 jest.dontMock('@agent/state/global');
1023 });
1024
1025 // Clicking a picker block after a non-picker
1026 // block was selected used to lose the hover bar that renderBar
1027 // just mounted. `onEditClick` calls `setCommittedSelection(null)`
1028 // before `setSelected({...image})`, and the broad unsubSelected
1029 // subscriber fired on the first write while state.selected was
1030 // still the prior text block, hit the non-picker clearBar branch,
1031 // and tore the bar down. The picker mounted via React with no
1032 // pills, no outline — see missing-pills-after-text.spec.ts. Gate
1033 // on actual `selected` change to keep the bar alive.
1034 it('non-picker → picker click: hover bar survives the transition', () => {
1035 window.extAgentData = {};
1036 const { hoverBar, store } = setEditModeOn();
1037 const paragraph = tagged(tag('p', ['wp-block-paragraph']), 700);
1038 const figure = tagged(tag('figure', ['wp-block-image']), 701);
1039 document.body.appendChild(paragraph);
1040 document.body.appendChild(figure);
1041 hoverBar.attach();
1042
1043 dispatchClick(paragraph);
1044 expect(store.getState().selected?.el).toBe(paragraph);
1045 expect(document.querySelector(BAR_SEL)).toBeNull();
1046
1047 dispatchClick(figure);
1048
1049 expect(store.getState().selected?.el).toBe(figure);
1050 expect(store.getState().selected?.blockType).toBe('core/image');
1051 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1052
1053 hoverBar.detach();
1054 });
1055
1056 // Picker-type two-pill blocks (image, cover)
1057 // are exempt from the silent agent stage. The bar stays mounted
1058 // for picker types and keeps the Ask AI pill, so the user can
1059 // still escalate by clicking it. Auto-staging here would surface
1060 // BOTH the QE picker dropdown AND the agent's DOMHighlighter
1061 // X-close on the same block.
1062 it('picker-type two-pill + agent open: click opens QE, does NOT stage agentBlock', async () => {
1063 window.extAgentData = {};
1064 jest.doMock('@agent/state/global', () => ({
1065 useGlobalStore: {
1066 getState: () => ({ open: true, setOpen: jest.fn() }),
1067 subscribe: () => () => {},
1068 },
1069 }));
1070
1071 const { hoverBar, store } = setEditModeOn();
1072 const figure = tagged(tag('figure', ['wp-block-image']), 600);
1073 document.body.appendChild(figure);
1074 hoverBar.attach();
1075 await new Promise((resolve) => setTimeout(resolve, 0));
1076
1077 dispatchClick(figure);
1078
1079 expect(store.getState().selected).not.toBeNull();
1080 expect(store.getState().selected.el).toBe(figure);
1081 expect(store.getState().selected.blockType).toBe('core/image');
1082 expect(store.getState().agentBlock).toBeNull();
1083 expect(store.getState().committedSelection).toBeNull();
1084 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1085
1086 hoverBar.detach();
1087 jest.dontMock('@agent/state/global');
1088 });
1089
1090 // Issue 19: the select branch's stopPropagation keeps ImagePicker's
1091 // bubble-phase outside-click from firing, so the branch itself must
1092 // drop a stale picker selection or the image menu lingers.
1093 it('picker selected → click an Ask-AI-only block (agent closed): clears selected, commits the new block', () => {
1094 window.extAgentData = {};
1095 jest.doMock('@agent/state/global', () => ({
1096 useGlobalStore: {
1097 getState: () => ({ open: false, setOpen: jest.fn() }),
1098 subscribe: () => () => {},
1099 },
1100 }));
1101
1102 const { hoverBar, store } = setEditModeOn();
1103 const figure = tagged(tag('figure', ['wp-block-image']), 800);
1104 const group = tagged(div(['wp-block-group']), 801);
1105 document.body.append(figure, group);
1106 hoverBar.attach();
1107
1108 dispatchClick(figure);
1109 expect(store.getState().selected?.blockType).toBe('core/image');
1110
1111 dispatchClick(group);
1112
1113 expect(store.getState().selected).toBeNull();
1114 expect(store.getState().committedSelection?.el).toBe(group);
1115
1116 hoverBar.detach();
1117 jest.dontMock('@agent/state/global');
1118 });
1119
1120 it('picker selected → click an Ask-AI-only block (agent open): clears selected, stages agentBlock', async () => {
1121 window.extAgentData = {};
1122 jest.doMock('@agent/state/global', () => ({
1123 useGlobalStore: {
1124 getState: () => ({ open: true, setOpen: jest.fn() }),
1125 subscribe: () => () => {},
1126 },
1127 }));
1128
1129 const { hoverBar, store } = setEditModeOn();
1130 const figure = tagged(tag('figure', ['wp-block-image']), 810);
1131 const group = tagged(div(['wp-block-group']), 811);
1132 document.body.append(figure, group);
1133 hoverBar.attach();
1134 await new Promise((resolve) => setTimeout(resolve, 0));
1135
1136 dispatchClick(figure);
1137 expect(store.getState().selected?.blockType).toBe('core/image');
1138
1139 dispatchClick(group);
1140
1141 expect(store.getState().selected).toBeNull();
1142 expect(store.getState().agentBlock?.id).toBe('811');
1143
1144 hoverBar.detach();
1145 jest.dontMock('@agent/state/global');
1146 });
1147
1148 // The stale-selection clear must not break the same-image toggle —
1149 // it only fires when the click lands on a different element.
1150 it('picker selected → click the same image again still toggles the selection off', () => {
1151 window.extAgentData = {};
1152 const { hoverBar, store } = setEditModeOn();
1153 const figure = tagged(tag('figure', ['wp-block-image']), 820);
1154 document.body.appendChild(figure);
1155 hoverBar.attach();
1156
1157 dispatchClick(figure);
1158 expect(store.getState().selected?.blockType).toBe('core/image');
1159
1160 dispatchClick(figure);
1161
1162 expect(store.getState().selected).toBeNull();
1163
1164 hoverBar.detach();
1165 });
1166
1167 // A product:image is a picker type via
1168 // InlineEditor's PICKER_STRATEGIES (routes through ImagePicker
1169 // alongside core/image / core/cover) but was missing from
1170 // hover-bar's isPickerType set. Ask AI is ineligible for product
1171 // sources (source.kind === 'product'), so the click enters the
1172 // QE-only cell, then onEditClick fires clearBar before setSelected
1173 // because product:image didn't match isPickerType — picker dropdown
1174 // mounted via React with no hover bar anchoring it. Reproduces on
1175 // WooCommerce patterns.
1176 it('product:image QE-only click: hover bar survives (picker stays anchored)', () => {
1177 const { hoverBar, store } = setEditModeOn();
1178 const productImage = tag('img', ['wp-block-image']);
1179 productImage.setAttribute('data-extendify-quick-edit-product-id', '42');
1180 productImage.setAttribute(
1181 'data-extendify-quick-edit-product-field',
1182 'image',
1183 );
1184 document.body.appendChild(productImage);
1185 hoverBar.attach();
1186
1187 dispatchClick(productImage);
1188
1189 expect(store.getState().selected?.el).toBe(productImage);
1190 expect(store.getState().selected?.blockType).toBe('product:image');
1191 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1192
1193 hoverBar.detach();
1194 });
1195
1196 // core/media-text keeps its image on a block attribute, so the media
1197 // <figure> is tagged directly (MediaTextTagger). A click on the image
1198 // resolves to the synthetic picker type core/media-text:image while
1199 // selection lands on the media-text wrapper (el), carrying the figure
1200 // as mediaEl and the real block name for SaveController. Picker type →
1201 // the bar stays anchored, same as core/image / product:image.
1202 it('media-text image click: resolves to core/media-text:image, bar stays anchored', () => {
1203 const { hoverBar, store } = setEditModeOn();
1204 const wrapper = tagged(div(['wp-block-media-text']), 50);
1205 const figure = tag('figure', ['wp-block-media-text__media']);
1206 figure.setAttribute('data-extendify-quick-edit-mediatext-media', '1');
1207 const image = tag('img');
1208 figure.appendChild(image);
1209 wrapper.appendChild(figure);
1210 document.body.appendChild(wrapper);
1211 hoverBar.attach();
1212
1213 dispatchClick(image);
1214
1215 const selected = store.getState().selected;
1216 expect(selected).not.toBeNull();
1217 expect(selected.el).toBe(wrapper);
1218 expect(selected.mediaEl).toBe(figure);
1219 expect(selected.blockType).toBe('core/media-text:image');
1220 expect(selected.blockName).toBe('core/media-text');
1221 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1222
1223 hoverBar.detach();
1224 });
1225
1226 // The block element wraps both the image and the text column, but the
1227 // selector should hug just the image — anchor the outline to the
1228 // media <figure> (mediaEl), not the whole media-text block.
1229 it('media-text image: outline anchors to the figure, not the whole block', () => {
1230 const { hoverBar } = setEditModeOn();
1231 const wrapper = tagged(div(['wp-block-media-text']), 50);
1232 const figure = tag('figure', ['wp-block-media-text__media']);
1233 figure.setAttribute('data-extendify-quick-edit-mediatext-media', '1');
1234 const image = tag('img');
1235 figure.appendChild(image);
1236 wrapper.appendChild(figure);
1237 document.body.appendChild(wrapper);
1238 wrapper.getBoundingClientRect = () => ({
1239 top: 50,
1240 left: 50,
1241 width: 500,
1242 height: 300,
1243 bottom: 350,
1244 right: 550,
1245 });
1246 figure.getBoundingClientRect = () => ({
1247 top: 100,
1248 left: 200,
1249 width: 60,
1250 height: 40,
1251 bottom: 140,
1252 right: 260,
1253 });
1254 hoverBar.attach();
1255
1256 dispatchClick(image);
1257
1258 const outline = document.querySelector(
1259 '.extendify-quick-edit-hover-outline',
1260 );
1261 expect(outline).not.toBeNull();
1262 expect(outline.style.top).toBe('100px');
1263 expect(outline.style.left).toBe('200px');
1264 expect(outline.style.width).toBe('60px');
1265 expect(outline.style.height).toBe('40px');
1266
1267 hoverBar.detach();
1268 });
1269 });
1270
1271 // Implicit-close gestures (cross-block click,
1272 // whitespace click) save the in-flight QE edits instead of discarding
1273 // them. The bridge is a no-op when no canvas is open (no saver
1274 // registered) so picker-block flows + the rest of the click rule are
1275 // untouched.
1276 describe('implicit-close save bridge', () => {
1277 const dispatchClick = (el) => {
1278 el.dispatchEvent(
1279 new MouseEvent('click', { bubbles: true, cancelable: true }),
1280 );
1281 };
1282
1283 afterEach(() => {
1284 document.body.innerHTML = '';
1285 delete window.extAgentData;
1286 });
1287
1288 it('cross-block click while QE is open saves A with alsoClear: false', () => {
1289 jest.resetModules();
1290 const hoverBar = require('@quick-edit/lib/hover-bar');
1291 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1292 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1293 const {
1294 registerSaver,
1295 unregisterSaver,
1296 } = require('@quick-edit/lib/save-bridge');
1297 useEditModeStore.setState({ on: true });
1298
1299 const a = tagged(tag('p', ['wp-block-paragraph']), 'a');
1300 const b = tagged(tag('p', ['wp-block-paragraph']), 'b');
1301 document.body.appendChild(a);
1302 document.body.appendChild(b);
1303
1304 const saver = jest.fn();
1305 registerSaver(saver);
1306 store.getState().setSelected({
1307 el: a,
1308 blockType: 'core/paragraph',
1309 blockId: 'a',
1310 source: { kind: 'post', id: 1 },
1311 });
1312
1313 hoverBar.attach();
1314 dispatchClick(b);
1315
1316 expect(saver).toHaveBeenCalledTimes(1);
1317 expect(saver).toHaveBeenCalledWith({ alsoClear: false });
1318 expect(store.getState().selected?.el).toBe(b);
1319
1320 unregisterSaver(saver);
1321 hoverBar.detach();
1322 });
1323
1324 it('whitespace click while QE is open saves with alsoClear: true', () => {
1325 jest.resetModules();
1326 const hoverBar = require('@quick-edit/lib/hover-bar');
1327 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1328 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1329 const {
1330 registerSaver,
1331 unregisterSaver,
1332 } = require('@quick-edit/lib/save-bridge');
1333 useEditModeStore.setState({ on: true });
1334
1335 const a = tagged(tag('p', ['wp-block-paragraph']), 'a');
1336 const whitespace = document.createElement('div');
1337 document.body.appendChild(a);
1338 document.body.appendChild(whitespace);
1339
1340 const saver = jest.fn();
1341 registerSaver(saver);
1342 store.getState().setSelected({
1343 el: a,
1344 blockType: 'core/paragraph',
1345 blockId: 'a',
1346 source: { kind: 'post', id: 1 },
1347 });
1348
1349 hoverBar.attach();
1350 dispatchClick(whitespace);
1351
1352 expect(saver).toHaveBeenCalledTimes(1);
1353 expect(saver).toHaveBeenCalledWith({ alsoClear: true });
1354 // selected stays — handleSave's own clearSelected is what
1355 // unmounts the canvas asynchronously on success.
1356 expect(store.getState().selected?.el).toBe(a);
1357
1358 unregisterSaver(saver);
1359 hoverBar.detach();
1360 });
1361
1362 // Tagged-but-no-QE-handler block (e.g. spacer). The user gesture
1363 // is "I'm done with this canvas" — save and close. Without this
1364 // carve-out, alsoClear: false would leave the canvas mounted on A
1365 // because the new tagged target opens nothing.
1366 it('clicking a tagged-but-not-QE block (spacer) saves with alsoClear: true', () => {
1367 jest.resetModules();
1368 const hoverBar = require('@quick-edit/lib/hover-bar');
1369 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1370 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1371 const {
1372 registerSaver,
1373 unregisterSaver,
1374 } = require('@quick-edit/lib/save-bridge');
1375 useEditModeStore.setState({ on: true });
1376
1377 const a = tagged(tag('p', ['wp-block-paragraph']), 'a');
1378 const spacer = tagged(div(['wp-block-spacer']), 'spacer');
1379 document.body.appendChild(a);
1380 document.body.appendChild(spacer);
1381
1382 const saver = jest.fn();
1383 registerSaver(saver);
1384 store.getState().setSelected({
1385 el: a,
1386 blockType: 'core/paragraph',
1387 blockId: 'a',
1388 source: { kind: 'post', id: 1 },
1389 });
1390
1391 hoverBar.attach();
1392 dispatchClick(spacer);
1393
1394 expect(saver).toHaveBeenCalledTimes(1);
1395 expect(saver).toHaveBeenCalledWith({ alsoClear: true });
1396
1397 unregisterSaver(saver);
1398 hoverBar.detach();
1399 });
1400
1401 it("no saver registered: cross-block click falls through to today's overwrite behavior", () => {
1402 jest.resetModules();
1403 const hoverBar = require('@quick-edit/lib/hover-bar');
1404 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1405 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1406 useEditModeStore.setState({ on: true });
1407
1408 const a = tagged(tag('p', ['wp-block-paragraph']), 'a');
1409 const b = tagged(tag('p', ['wp-block-paragraph']), 'b');
1410 document.body.appendChild(a);
1411 document.body.appendChild(b);
1412
1413 store.getState().setSelected({
1414 el: a,
1415 blockType: 'core/paragraph',
1416 blockId: 'a',
1417 source: { kind: 'post', id: 1 },
1418 });
1419
1420 hoverBar.attach();
1421 dispatchClick(b);
1422
1423 expect(store.getState().selected?.el).toBe(b);
1424
1425 hoverBar.detach();
1426 });
1427
1428 it("no saver + whitespace click: canvas stays open (today's behavior)", () => {
1429 jest.resetModules();
1430 const hoverBar = require('@quick-edit/lib/hover-bar');
1431 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1432 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1433 useEditModeStore.setState({ on: true });
1434
1435 const a = tagged(tag('p', ['wp-block-paragraph']), 'a');
1436 const whitespace = document.createElement('div');
1437 document.body.appendChild(a);
1438 document.body.appendChild(whitespace);
1439
1440 store.getState().setSelected({
1441 el: a,
1442 blockType: 'core/paragraph',
1443 blockId: 'a',
1444 source: { kind: 'post', id: 1 },
1445 });
1446
1447 hoverBar.attach();
1448 dispatchClick(whitespace);
1449
1450 expect(store.getState().selected?.el).toBe(a);
1451
1452 hoverBar.detach();
1453 });
1454 });
1455
1456 // Esc re-engagement — when `selected` transitions non-null
1457 // → null (canvas closes), the bar re-renders on the previously edited
1458 // element without waiting for a mouseover. The fix sidesteps the
1459 // mouseover-needs-element-boundary timing: same-block re-entry doesn't
1460 // fire mouseover, so without the force-render the bar stays gone
1461 // until the user nudges the cursor onto a different block.
1462 describe('selected → null re-renders the bar (Esc re-engagement)', () => {
1463 it('re-renders the bar on the previously edited element', () => {
1464 jest.resetModules();
1465 const hoverBar = require('@quick-edit/lib/hover-bar');
1466 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1467 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1468 useEditModeStore.setState({ on: true });
1469
1470 const p = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
1471 document.body.appendChild(p);
1472
1473 hoverBar.attach();
1474
1475 store.getState().setSelected({
1476 el: p,
1477 blockType: 'core/paragraph',
1478 blockId: 'b-1',
1479 source: { kind: 'post', id: 1 },
1480 });
1481 // Selected → bar cleared.
1482 expect(document.querySelector(BAR_SEL)).toBeNull();
1483
1484 store.getState().setSelected(null);
1485
1486 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1487
1488 hoverBar.detach();
1489 });
1490
1491 it('does not re-render when transitioning null → null', () => {
1492 jest.resetModules();
1493 const hoverBar = require('@quick-edit/lib/hover-bar');
1494 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1495 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1496 useEditModeStore.setState({ on: true });
1497
1498 hoverBar.attach();
1499 expect(document.querySelector(BAR_SEL)).toBeNull();
1500
1501 // No prior selection — clearing again should not synthesize a bar.
1502 store.getState().setSelected(null);
1503 expect(document.querySelector(BAR_SEL)).toBeNull();
1504
1505 hoverBar.detach();
1506 });
1507
1508 it('does not re-render when the previous element has detached from the DOM', () => {
1509 jest.resetModules();
1510 const hoverBar = require('@quick-edit/lib/hover-bar');
1511 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1512 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1513 useEditModeStore.setState({ on: true });
1514
1515 const p = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
1516 document.body.appendChild(p);
1517
1518 hoverBar.attach();
1519
1520 store.getState().setSelected({
1521 el: p,
1522 blockType: 'core/paragraph',
1523 blockId: 'b-1',
1524 source: { kind: 'post', id: 1 },
1525 });
1526
1527 // Mimic save: block is replaced (its DOM node detaches).
1528 p.remove();
1529 store.getState().setSelected(null);
1530
1531 expect(document.querySelector(BAR_SEL)).toBeNull();
1532
1533 hoverBar.detach();
1534 });
1535
1536 it('does not re-render when edit mode is off', () => {
1537 jest.resetModules();
1538 const hoverBar = require('@quick-edit/lib/hover-bar');
1539 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1540 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1541 useEditModeStore.setState({ on: true });
1542
1543 const p = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
1544 document.body.appendChild(p);
1545
1546 hoverBar.attach();
1547 store.getState().setSelected({
1548 el: p,
1549 blockType: 'core/paragraph',
1550 blockId: 'b-1',
1551 source: { kind: 'post', id: 1 },
1552 });
1553
1554 useEditModeStore.setState({ on: false });
1555 store.getState().setSelected(null);
1556
1557 expect(document.querySelector(BAR_SEL)).toBeNull();
1558
1559 hoverBar.detach();
1560 });
1561
1562 // Esc/Cancel/Save/programmatic
1563 // clearSelected on the same block the agent is staged on must also
1564 // clear the agent stage; otherwise the dashed outline + X-close
1565 // linger on a block the user dismissed.
1566 it('clears agentBlock when QE canvas closes on the same block', () => {
1567 jest.resetModules();
1568 const hoverBar = require('@quick-edit/lib/hover-bar');
1569 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1570 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1571 useEditModeStore.setState({ on: true });
1572
1573 const p = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
1574 document.body.appendChild(p);
1575
1576 hoverBar.attach();
1577 store.setState({
1578 selected: {
1579 el: p,
1580 blockType: 'core/paragraph',
1581 blockId: 'b-1',
1582 source: { kind: 'post', id: 1 },
1583 },
1584 agentBlock: { id: 'b-1', target: 'data-extendify-agent-block-id' },
1585 agentBlockCode: null,
1586 });
1587
1588 const cancelListener = jest.fn();
1589 window.addEventListener(
1590 'extendify-agent:cancel-workflow',
1591 cancelListener,
1592 );
1593
1594 store.getState().setSelected(null);
1595
1596 expect(store.getState().agentBlock).toBeNull();
1597 expect(cancelListener).toHaveBeenCalledTimes(1);
1598
1599 window.removeEventListener(
1600 'extendify-agent:cancel-workflow',
1601 cancelListener,
1602 );
1603 hoverBar.detach();
1604 });
1605
1606 // The cross-block weird-state: canvas on A but agent staged on B.
1607 // Closing the canvas should NOT touch agent B — it isn't the same
1608 // block the user just dismissed.
1609 it('leaves agentBlock alone when canvas closes on a DIFFERENT block', () => {
1610 jest.resetModules();
1611 const hoverBar = require('@quick-edit/lib/hover-bar');
1612 const { useQuickEditStore: store } = require('@quick-edit/state/store');
1613 const { useEditModeStore } = require('@quick-edit/state/edit-mode');
1614 useEditModeStore.setState({ on: true });
1615
1616 const p = tagged(tag('p', ['wp-block-paragraph']), 'b-1');
1617 document.body.appendChild(p);
1618
1619 hoverBar.attach();
1620 store.setState({
1621 selected: {
1622 el: p,
1623 blockType: 'core/paragraph',
1624 blockId: 'b-1',
1625 source: { kind: 'post', id: 1 },
1626 },
1627 agentBlock: {
1628 id: 'b-OTHER',
1629 target: 'data-extendify-agent-block-id',
1630 },
1631 agentBlockCode: null,
1632 });
1633
1634 store.getState().setSelected(null);
1635
1636 expect(store.getState().agentBlock?.id).toBe('b-OTHER');
1637
1638 hoverBar.detach();
1639 });
1640 });
1641
1642 // 0c0170da — buildTarget walks past a tagged ancestor with a null
1643 // blockType (KNOWN_UNSUPPORTED post-title) to the next tagged ancestor
1644 // (cover) so renderBar has something to offer.
1645 it('walks past a tagged unsupported ancestor (post-title) to the next editable tagged ancestor (cover)', () => {
1646 const cover = tagged(div(['wp-block-cover']), 11);
1647 const inner = div(['wp-block-cover__inner-container']);
1648 const postTitle = tagged(tag('h1', ['wp-block-post-title']), 12);
1649 inner.appendChild(postTitle);
1650 cover.appendChild(inner);
1651 document.body.appendChild(cover);
1652
1653 showBar(postTitle);
1654
1655 expect(document.querySelector(BAR_SEL)).not.toBeNull();
1656 const selected = clickQuickEdit();
1657 expect(selected.el).toBe(cover);
1658 expect(selected.blockType).toBe('core/cover');
1659 expect(selected.blockId).toBe(11);
1660 });
1661 });
1662