Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 | import { select } from '@wordpress/data';
import { createRoot, createElement } from '@wordpress/element';
import BlockSuggestionActions from '../components/block-suggestion-actions';
import BlockSuggestionButtons from '../components/block-suggestion-buttons';
import EmptyStateBanner from '../components/empty-state-banner';
import ReadMoreLink from '../components/read-more-link';
import SectionGenerateButton from '../components/section-generate-button';
import SuggestAllButton from '../components/suggest-all-button';
import SuggestionActions from '../components/suggestion-actions';
import SuggestionBadge from '../components/suggestion-badge';
import UpgradeNotice from '../components/upgrade-notice';
import { VALID_SECTIONS } from '../constants';
import { getBlockModalTextarea, startDraftTracking } from './drafts';
// Each injection point tracks both the DOM container and its React root.
// Before re-injecting, we unmount the old root to ensure proper cleanup
// of effects and subscriptions. We verify containers via isConnected since
// Gutenberg's <Navigator> removes/re-adds the main screen DOM when
// navigating to revision history and back.
const slots = {
header: { container: null, root: null },
'read-more': { container: null, root: null },
'upgrade-notice': { container: null, root: null },
banner: { container: null, root: null },
};
for ( const slug of VALID_SECTIONS ) {
slots[ `badge-${ slug }` ] = { container: null, root: null };
slots[ `actions-${ slug }` ] = { container: null, root: null };
slots[ `button-${ slug }` ] = { container: null, root: null };
}
slots[ 'block-actions' ] = { container: null, root: null };
slots[ 'block-suggestion-buttons' ] = { container: null, root: null };
// Block name the block-modal slots were last rendered with. Lets runAll()
// detect when the create-mode combobox switches to a different block while
// the slots are still mounted.
let lastBlockName = null;
/**
* Inject a React component into the DOM, reusing or replacing the slot.
*
* @param {string} key - Slot key in the slots map.
* @param {Function} findParent - Returns { parent, before, className } or null.
* @param {Function} Component - React component to render.
* @param {Object} [props] - Props to pass to the component.
*/
function inject( key, findParent, Component, props ) {
const slot = slots[ key ];
// Already injected and still in DOM — nothing to do.
if ( slot.container?.isConnected ) {
return;
}
// Container was removed — unmount the old root to clean up effects.
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
slot.container = null;
}
const target = findParent();
if ( ! target ) {
return;
}
const { parent, before, className, tag } = target;
const container = document.createElement( tag || 'div' );
container.className = className;
if ( before ) {
parent.insertBefore( container, before );
} else {
parent.appendChild( container );
}
const root = createRoot( container );
root.render( createElement( Component, props ) );
slot.container = container;
slot.root = root;
}
/**
* Unmount a slot's React root and remove its container from the DOM.
*
* Unlike inject()'s cleanup path, the container may still be connected —
* used when a slot must re-render with different props (e.g. the block
* modal's combobox switching blocks).
*
* @param {string} key - Slot key in the slots map.
*/
function unmountSlot( key ) {
const slot = slots[ key ];
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
}
if ( slot.container ) {
slot.container.remove();
slot.container = null;
}
}
/**
* Extract the block name from the block guideline modal.
* In editing mode, reads the disabled TextControl and matches against block types.
* In creating mode, reads the ComboboxControl's selected value.
*/
function getBlockNameFromModal( modal ) {
const blockTypes = select( 'core/blocks' ).getBlockTypes();
// Editing mode: disabled input shows block title.
const disabledInput = modal.querySelector( 'input[disabled]' );
if ( disabledInput?.value ) {
return blockTypes.find( b => b.title === disabledInput.value )?.name;
}
// Creating mode: combobox with selected value.
const combobox = modal.querySelector( 'input[role="combobox"]' );
if ( combobox?.value ) {
return blockTypes.find( b => b.title === combobox.value )?.name;
}
return null;
}
/**
* Locate the wp-admin Page header title row — the space-between flex row
* containing the page <h1>. All header classes are hashed CSS-module names,
* so we walk up from the heading structurally.
*
* @return {HTMLElement|null} The row element, or null when not found.
*/
function findHeaderRow() {
const region = document.querySelector( '.admin-ui-navigable-region' );
const heading = region?.querySelector( 'h1' );
let row = heading?.parentElement;
while ( row && row !== region ) {
const style = window.getComputedStyle( row );
if (
style.display === 'flex' &&
style.flexDirection === 'row' &&
style.justifyContent.includes( 'between' )
) {
break;
}
row = row.parentElement;
}
return row && row !== region ? row : null;
}
function runAll() {
// Header button — right-aligned in the wp-admin Page header, where the
// native header actions would render. The gutenberg page passes no
// `actions` to <Page>, so that slot is never created; instead we append
// the button as the title row's second child so space-between pushes it
// to the right.
inject(
'header',
() => {
// Wait until the guidelines have loaded before mounting the button.
// Gutenberg renders the list only after its async fetch resolves
// (a spinner shows until then); mounting earlier reads the empty
// default store and flickers the label "Generate" -> "Improve".
if ( ! document.querySelector( '.guidelines__list' ) ) {
return null;
}
const row = findHeaderRow();
return row
? {
parent: row,
className: 'jetpack-content-guidelines-ai__header-container',
}
: null;
},
SuggestAllButton
);
// "Read more" support link — inline at the end of the header subtitle,
// the description <p> that follows the title row (its class is a hashed
// CSS-module name, so it's located structurally). Unlike the header
// button, there is no store data to wait for, so don't gate on
// `.guidelines__list` (which only exists after the async guidelines
// fetch) — inject as soon as the header renders so the link paints
// together with the description instead of popping in seconds later.
// The revision-history screen needs no explicit exclusion: it renders
// no Page header, so findHeaderRow() finds nothing there.
inject(
'read-more',
() => {
const subtitle = findHeaderRow()?.nextElementSibling;
return subtitle?.tagName === 'P'
? {
parent: subtitle,
className: 'jetpack-content-guidelines-ai__read-more-container',
tag: 'span',
}
: null;
},
ReadMoreLink
);
// Upgrade notice — shown above the guideline list when AI is unavailable.
inject(
'upgrade-notice',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__upgrade-notice-container',
}
: null;
},
UpgradeNotice
);
// Empty state banner.
inject(
'banner',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__banner-container',
}
: null;
},
EmptyStateBanner
);
// Per-section injections. Sections are matched by the stable `data-slug`
// attribute on each `.guidelines__list-item`. The per-section form is
// always present in the DOM (the CollapsibleCard keeps its content mounted
// while collapsed).
for ( const slug of VALID_SECTIONS ) {
// Steady-state fast path: once this section's three slots are injected
// and still connected, there is nothing to do — skip the DOM queries so
// the observer's per-frame work stays cheap. If the <Navigator> removes
// the screen, the containers disconnect and we fall through to re-inject.
if (
slots[ `badge-${ slug }` ].container?.isConnected &&
slots[ `actions-${ slug }` ].container?.isConnected &&
slots[ `button-${ slug }` ].container?.isConnected
) {
continue;
}
const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
const form = item?.querySelector( 'form' );
if ( ! form ) {
continue;
}
// Badge in the accordion header, to the left of the chevron.
//
// CollapsibleCard.Header renders the heading wrapping a flex trigger row
// whose children are the title/description block and, last, the chevron's
// positioner. Appending to the row puts the badge to the right of the
// chevron, which pushes the chevron leftward only on sections that have a
// badge — so chevrons no longer line up across sections. Insert the badge
// before the chevron instead: the chevron stays the last child (flush to
// the right and aligned across every section) and the badge sits just to
// its left.
inject(
`badge-${ slug }`,
() => {
const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
const trigger = heading?.firstElementChild ?? heading;
const chevron = trigger?.lastElementChild;
return trigger
? {
parent: trigger,
before: chevron,
className: 'jetpack-content-guidelines-ai__badge-container',
tag: 'span',
}
: null;
},
SuggestionBadge,
{ slug }
);
// Suggestion actions (diff + accept/dismiss) at top of form.
inject(
`actions-${ slug }`,
() => {
const vStack = form.firstElementChild;
return vStack
? {
parent: vStack,
before: vStack.firstChild,
className: 'jetpack-content-guidelines-ai__actions-container',
}
: null;
},
SuggestionActions,
{ slug }
);
// Per-section generate button next to the Save button (the form's
// primary submit button lives in an HStack with the Clear button).
inject(
`button-${ slug }`,
() => {
const saveButton = form.querySelector( 'button[type="submit"]' );
const hStack = saveButton?.parentElement;
return hStack
? {
parent: hStack,
className: 'jetpack-content-guidelines-ai__section-button-container',
}
: null;
},
SectionGenerateButton,
{ slug }
);
}
// Block guideline modal injections.
const blockModal = document.querySelector( '.block-guideline-modal' );
// Resolve the block name on every pass while the modal is open — even when
// the slots are already mounted. In create mode the user can switch the
// combobox to a different block after our components rendered, and
// blockName is bound as a prop at render time. When the resolved name
// changes, tear the mounted slots down so they re-inject below with the
// new name; otherwise a suggestion generated for the previous block would
// be accepted into the newly selected one. Unmounting also runs
// BlockSuggestionActions' cleanup, clearing the stale suggestion from the
// store.
const blockName = blockModal ? getBlockNameFromModal( blockModal ) : null;
if ( blockName !== lastBlockName ) {
unmountSlot( 'block-actions' );
unmountSlot( 'block-suggestion-buttons' );
lastBlockName = blockName;
}
// Always invoke inject so previously mounted roots get unmounted when
// the modal closes — findParent() returns null for "no place to inject"
// and inject()'s cleanup path handles the disconnected container.
inject(
'block-actions',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const textareaInput = getBlockModalTextarea( blockModal );
const field = textareaInput?.parentElement;
return field
? {
parent: field,
before: textareaInput,
className: 'jetpack-content-guidelines-ai__block-actions-container',
}
: null;
},
BlockSuggestionActions,
{ blockName, blockModal }
);
inject(
'block-suggestion-buttons',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const actionsBar = blockModal.querySelector( '.block-guideline-modal__actions' );
const vStack = actionsBar?.parentElement;
return vStack
? {
parent: vStack,
before: actionsBar,
className: 'jetpack-content-guidelines-ai__block-suggestion-buttons-container',
}
: null;
},
BlockSuggestionButtons,
{ blockName, blockModal }
);
}
/**
* Start observing DOM and inject all components.
*
* We observe document.body (not a narrower container) for two reasons:
* 1. WordPress Modal portals render directly on document.body — the block
* guideline modal lives outside any Gutenberg container, so a narrower
* root would miss it appearing.
* 2. Gutenberg's Navigator can remove and re-add the main screen DOM
* (e.g. revision history navigation), so we can't rely on a specific
* container staying connected.
*
* The observer never disconnects for the same reasons. Callbacks are
* debounced via requestAnimationFrame so runAll() fires at most once
* per frame, and each inject() call is a no-op when its container is
* still connected.
*/
let injectionStarted = false;
export function startInjection() {
if ( injectionStarted ) {
return;
}
injectionStarted = true;
startDraftTracking();
runAll();
let scheduled = false;
const observer = new MutationObserver( () => {
if ( ! scheduled ) {
scheduled = true;
requestAnimationFrame( () => {
scheduled = false;
runAll();
} );
}
} );
observer.observe( document.body, { childList: true, subtree: true } );
}
|