| 1 |
/** |
| 2 |
* The AI Actions split button, drawn in the editor canvas. |
| 3 |
* |
| 4 |
* Same markup and classes as views/templates/parts/ai-actions.php, so the |
| 5 |
* frontend stylesheet dresses it without a line of editor-only CSS, and so the |
| 6 |
* block's style controls — which select on `.betterdocs-ai-actions-primary` and |
| 7 |
* friends — hit the preview exactly as they hit the real thing. |
| 8 |
* |
| 9 |
* It is deliberately inert. The frontend delegates clicks from `body` in |
| 10 |
* public/betterdocs.js, which never runs in the editor, so the disclosure is |
| 11 |
* driven by React state here instead. Nothing navigates and nothing touches the |
| 12 |
* clipboard: the point is to show the author what the toggles do, not to be the |
| 13 |
* button. |
| 14 |
*/ |
| 15 |
|
| 16 |
import { __ } from "@wordpress/i18n"; |
| 17 |
import { Fragment, useEffect, useRef, useState } from "@wordpress/element"; |
| 18 |
import { caretMarkup, enabledActions } from "./aiActions"; |
| 19 |
import { copyIcon, caretIcon } from "./aiIcons"; |
| 20 |
|
| 21 |
/** |
| 22 |
* Registry icons arrive as SVG markup rather than components, because they come |
| 23 |
* from Core\AIActions::icon() — one source of truth for both surfaces, including |
| 24 |
* whatever a third-party registry entry supplies. |
| 25 |
*/ |
| 26 |
const Icon = ({ markup, className = "" }) => ( |
| 27 |
<span |
| 28 |
className={`betterdocs-ai-actions-icon${className ? ` ${className}` : ""}`} |
| 29 |
aria-hidden="true" |
| 30 |
dangerouslySetInnerHTML={{ __html: markup }} |
| 31 |
/> |
| 32 |
); |
| 33 |
|
| 34 |
/** |
| 35 |
* The static button, for when the localized registry is missing — a screen that |
| 36 |
* loaded the editor bundle without going through our enqueue, or a test. Better |
| 37 |
* an unopenable button than an empty block. |
| 38 |
*/ |
| 39 |
const Placeholder = ({ label }) => ( |
| 40 |
<div className="betterdocs-ai-actions betterdocs-ai-actions-placeholder"> |
| 41 |
<span className="betterdocs-ai-actions-primary"> |
| 42 |
<span className="betterdocs-ai-actions-icon">{copyIcon()}</span> |
| 43 |
<span className="betterdocs-ai-actions-label">{label}</span> |
| 44 |
</span> |
| 45 |
<span className="betterdocs-ai-actions-toggle"> |
| 46 |
<span className="betterdocs-ai-actions-caret">{caretIcon()}</span> |
| 47 |
</span> |
| 48 |
</div> |
| 49 |
); |
| 50 |
|
| 51 |
const AIActionsPreview = ({ attributes }) => { |
| 52 |
const { aiButtonLabel } = attributes; |
| 53 |
const [isOpen, setIsOpen] = useState(false); |
| 54 |
const wrapper = useRef(null); |
| 55 |
|
| 56 |
const actions = enabledActions(attributes); |
| 57 |
const primary = actions.find((action) => action.primary); |
| 58 |
const label = aiButtonLabel || (primary ? primary.label : __("AI Actions", "betterdocs")); |
| 59 |
|
| 60 |
// The canvas is an iframe in WP 6.3+, so these have to be bound to the |
| 61 |
// element's *own* document — `document` here is the admin frame's, which never |
| 62 |
// sees a click inside the canvas. |
| 63 |
useEffect(() => { |
| 64 |
if (!isOpen || !wrapper.current) { |
| 65 |
return; |
| 66 |
} |
| 67 |
|
| 68 |
const doc = wrapper.current.ownerDocument; |
| 69 |
const onDocumentClick = (event) => { |
| 70 |
if (wrapper.current && !wrapper.current.contains(event.target)) { |
| 71 |
setIsOpen(false); |
| 72 |
} |
| 73 |
}; |
| 74 |
const onKeyDown = (event) => { |
| 75 |
if (event.key === "Escape" || event.key === "Esc") { |
| 76 |
setIsOpen(false); |
| 77 |
} |
| 78 |
}; |
| 79 |
|
| 80 |
doc.addEventListener("click", onDocumentClick); |
| 81 |
doc.addEventListener("keydown", onKeyDown); |
| 82 |
|
| 83 |
return () => { |
| 84 |
doc.removeEventListener("click", onDocumentClick); |
| 85 |
doc.removeEventListener("keydown", onKeyDown); |
| 86 |
}; |
| 87 |
}, [isOpen]); |
| 88 |
|
| 89 |
if (!actions.length) { |
| 90 |
return <Placeholder label={label} />; |
| 91 |
} |
| 92 |
|
| 93 |
const caret = caretMarkup(); |
| 94 |
|
| 95 |
return ( |
| 96 |
<div |
| 97 |
className={`betterdocs-ai-actions${primary ? "" : " betterdocs-ai-actions-no-primary"}`} |
| 98 |
ref={wrapper} |
| 99 |
> |
| 100 |
{primary && ( |
| 101 |
<button |
| 102 |
type="button" |
| 103 |
className="betterdocs-ai-actions-primary" |
| 104 |
onClick={(event) => event.preventDefault()} |
| 105 |
> |
| 106 |
<Icon markup={primary.icon} className="betterdocs-ai-actions-icon-idle" /> |
| 107 |
<span className="betterdocs-ai-actions-label">{label}</span> |
| 108 |
</button> |
| 109 |
)} |
| 110 |
|
| 111 |
<button |
| 112 |
type="button" |
| 113 |
className="betterdocs-ai-actions-toggle" |
| 114 |
aria-expanded={isOpen} |
| 115 |
aria-label={__("AI actions", "betterdocs")} |
| 116 |
// Not stopPropagation: the click also has to reach Gutenberg, or |
| 117 |
// opening the menu on an unselected block would fail to select it. |
| 118 |
// The outside-click listener already ignores clicks inside the |
| 119 |
// wrapper, so nothing here closes what it just opened. |
| 120 |
onClick={(event) => { |
| 121 |
event.preventDefault(); |
| 122 |
setIsOpen(!isOpen); |
| 123 |
}} |
| 124 |
> |
| 125 |
{caret ? ( |
| 126 |
<Icon markup={caret} /> |
| 127 |
) : ( |
| 128 |
<span className="betterdocs-ai-actions-caret">{caretIcon()}</span> |
| 129 |
)} |
| 130 |
</button> |
| 131 |
|
| 132 |
<ul className="betterdocs-ai-actions-menu" hidden={!isOpen}> |
| 133 |
{actions.map((action, index) => ( |
| 134 |
<Fragment key={action.id}> |
| 135 |
{/* Same rule the frontend view draws: wherever the group |
| 136 |
changes between two consecutive rows. */} |
| 137 |
{index > 0 && action.group !== actions[index - 1].group && ( |
| 138 |
<li className="betterdocs-ai-actions-separator" role="presentation" /> |
| 139 |
)} |
| 140 |
<li className="betterdocs-ai-actions-row"> |
| 141 |
<button |
| 142 |
type="button" |
| 143 |
className="betterdocs-ai-actions-item" |
| 144 |
onClick={(event) => event.preventDefault()} |
| 145 |
> |
| 146 |
<Icon markup={action.icon} /> |
| 147 |
<span className="betterdocs-ai-actions-text"> |
| 148 |
<span className="betterdocs-ai-actions-title"> |
| 149 |
{action.label} |
| 150 |
</span> |
| 151 |
{action.description && ( |
| 152 |
<span className="betterdocs-ai-actions-desc"> |
| 153 |
{action.description} |
| 154 |
</span> |
| 155 |
)} |
| 156 |
</span> |
| 157 |
</button> |
| 158 |
</li> |
| 159 |
</Fragment> |
| 160 |
))} |
| 161 |
</ul> |
| 162 |
</div> |
| 163 |
); |
| 164 |
}; |
| 165 |
|
| 166 |
export default AIActionsPreview; |
| 167 |
|