383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
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 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';
|
|
|
|
// 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 },
|
|
'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;
|
|
}
|
|
|
|
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 target the
|
|
// header-content row (flex, justify: space-between) that holds the title and
|
|
// append the button as its second child so space-between pushes it to the
|
|
// right. All header classes are hashed CSS-module names, so we locate the
|
|
// row structurally: the space-between flex row containing the page <h1>.
|
|
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 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
|
|
? {
|
|
parent: row,
|
|
className: 'jetpack-content-guidelines-ai__header-container',
|
|
}
|
|
: null;
|
|
},
|
|
SuggestAllButton
|
|
);
|
|
|
|
// 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 (the section's heading element).
|
|
inject(
|
|
`badge-${ slug }`,
|
|
() => {
|
|
const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
|
|
const titleStack = heading?.firstElementChild ?? heading;
|
|
return titleStack
|
|
? {
|
|
parent: titleStack,
|
|
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 = blockModal.querySelector( '.components-textarea-control__input' );
|
|
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;
|
|
|
|
runAll();
|
|
|
|
let scheduled = false;
|
|
const observer = new MutationObserver( () => {
|
|
if ( ! scheduled ) {
|
|
scheduled = true;
|
|
requestAnimationFrame( () => {
|
|
scheduled = false;
|
|
runAll();
|
|
} );
|
|
}
|
|
} );
|
|
|
|
observer.observe( document.body, { childList: true, subtree: true } );
|
|
}
|