| 1 |
import { __, sprintf } from '@wordpress/i18n'; |
| 2 |
import { resolveTarget } from './dom'; |
| 3 |
import { |
| 4 |
askAiTarget, |
| 5 |
editTarget, |
| 6 |
hideBar, |
| 7 |
pillContextFor, |
| 8 |
showBar, |
| 9 |
} from './hover-bar'; |
| 10 |
|
| 11 |
const SELECTOR = [ |
| 12 |
'[data-extendify-agent-block-id]', |
| 13 |
'[data-extendify-part-block-id]', |
| 14 |
// Product/wpforms taggers write these; the agent's tagger doesn't reach those. |
| 15 |
'[data-extendify-quick-edit-product-id]', |
| 16 |
'[data-extendify-quick-edit-wpform-field-id]', |
| 17 |
].join(','); |
| 18 |
|
| 19 |
const KB_READY_ATTR = 'data-extendify-quick-edit-kb-ready'; |
| 20 |
const PREV_TAB_ATTR = 'data-extendify-quick-edit-kb-prev-tab'; |
| 21 |
const PREV_ROLE_ATTR = 'data-extendify-quick-edit-kb-prev-role'; |
| 22 |
const PREV_ARIA_ATTR = 'data-extendify-quick-edit-kb-prev-aria'; |
| 23 |
|
| 24 |
let attached = false; |
| 25 |
let onFocusIn = null; |
| 26 |
let onFocusOut = null; |
| 27 |
let onActivate = null; |
| 28 |
|
| 29 |
const findTagged = (start) => { |
| 30 |
let node = start; |
| 31 |
while (node && node.nodeType === 1 && node !== document.body) { |
| 32 |
if (node.matches?.(SELECTOR)) return node; |
| 33 |
node = node.parentElement; |
| 34 |
} |
| 35 |
return null; |
| 36 |
}; |
| 37 |
|
| 38 |
const buildAriaLabel = (el, target, { quickEditable, aiAvailable }) => { |
| 39 |
const text = (el.textContent || '').trim().replace(/\s+/g, ' '); |
| 40 |
const snippet = text.length > 60 ? `${text.substring(0, 60)}…` : text; |
| 41 |
// Ask-AI-only blocks (group / columns / media-text) route Enter to the |
| 42 |
// agent, so announce that action rather than "Edit". |
| 43 |
if (aiAvailable && !quickEditable) { |
| 44 |
return snippet |
| 45 |
? sprintf( |
| 46 |
// translators: %s is a snippet of the block's text content. |
| 47 |
__('Ask AI about "%s"', 'extendify-local'), |
| 48 |
snippet, |
| 49 |
) |
| 50 |
: __('Ask AI', 'extendify-local'); |
| 51 |
} |
| 52 |
const verb = |
| 53 |
target?.blockType === 'core/image' || target?.blockType === 'core/cover' |
| 54 |
? __('Replace image', 'extendify-local') |
| 55 |
: __('Edit', 'extendify-local'); |
| 56 |
return snippet |
| 57 |
? sprintf( |
| 58 |
// translators: %1$s is the action verb (Edit / Replace image), %2$s is a snippet of the block's text content. |
| 59 |
__('%1$s "%2$s"', 'extendify-local'), |
| 60 |
verb, |
| 61 |
snippet, |
| 62 |
) |
| 63 |
: verb; |
| 64 |
}; |
| 65 |
|
| 66 |
// Skips role="button" on headings/links/landmarks so SR users |
| 67 |
// retain heading/link/landmark navigation. Enter/Space still |
| 68 |
// activates regardless of role. |
| 69 |
const decorate = () => { |
| 70 |
for (const el of document.querySelectorAll(SELECTOR)) { |
| 71 |
if (el.getAttribute(KB_READY_ATTR) === '1') continue; |
| 72 |
// Empty-string sentinel distinguishes "wasn't set" from "was empty". |
| 73 |
el.setAttribute( |
| 74 |
PREV_TAB_ATTR, |
| 75 |
el.hasAttribute('tabindex') ? el.getAttribute('tabindex') : '', |
| 76 |
); |
| 77 |
el.setAttribute( |
| 78 |
PREV_ROLE_ATTR, |
| 79 |
el.hasAttribute('role') ? el.getAttribute('role') : '', |
| 80 |
); |
| 81 |
el.setAttribute( |
| 82 |
PREV_ARIA_ATTR, |
| 83 |
el.hasAttribute('aria-label') ? el.getAttribute('aria-label') : '', |
| 84 |
); |
| 85 |
el.setAttribute('tabindex', '0'); |
| 86 |
|
| 87 |
const tag = el.tagName; |
| 88 |
const isHeading = /^H[1-6]$/.test(tag); |
| 89 |
const isLink = tag === 'A'; |
| 90 |
const isLandmark = [ |
| 91 |
'NAV', |
| 92 |
'HEADER', |
| 93 |
'MAIN', |
| 94 |
'FOOTER', |
| 95 |
'ASIDE', |
| 96 |
'SECTION', |
| 97 |
].includes(tag); |
| 98 |
if (!isHeading && !isLink && !isLandmark) { |
| 99 |
el.setAttribute('role', 'button'); |
| 100 |
} |
| 101 |
|
| 102 |
const target = resolveTarget(el); |
| 103 |
el.setAttribute( |
| 104 |
'aria-label', |
| 105 |
buildAriaLabel(el, target, pillContextFor(target)), |
| 106 |
); |
| 107 |
el.setAttribute(KB_READY_ATTR, '1'); |
| 108 |
} |
| 109 |
}; |
| 110 |
|
| 111 |
const undecorate = () => { |
| 112 |
for (const el of document.querySelectorAll(`[${KB_READY_ATTR}="1"]`)) { |
| 113 |
const restore = (prevAttr, attr) => { |
| 114 |
const prev = el.getAttribute(prevAttr); |
| 115 |
if (prev !== '' && prev !== null) { |
| 116 |
el.setAttribute(attr, prev); |
| 117 |
} else { |
| 118 |
el.removeAttribute(attr); |
| 119 |
} |
| 120 |
el.removeAttribute(prevAttr); |
| 121 |
}; |
| 122 |
restore(PREV_TAB_ATTR, 'tabindex'); |
| 123 |
restore(PREV_ROLE_ATTR, 'role'); |
| 124 |
restore(PREV_ARIA_ATTR, 'aria-label'); |
| 125 |
el.removeAttribute(KB_READY_ATTR); |
| 126 |
} |
| 127 |
}; |
| 128 |
|
| 129 |
export const attachKeyboardEntry = ({ getSession }) => { |
| 130 |
if (attached) return; |
| 131 |
attached = true; |
| 132 |
|
| 133 |
decorate(); |
| 134 |
|
| 135 |
onFocusIn = (e) => { |
| 136 |
if (getSession?.()) return; |
| 137 |
const el = findTagged(e.target); |
| 138 |
if (!el) return; |
| 139 |
hideBar(); |
| 140 |
showBar(el); |
| 141 |
}; |
| 142 |
|
| 143 |
onFocusOut = (e) => { |
| 144 |
// Capture the focused element so we can detect "the element was |
| 145 |
// removed from the DOM" inside the setTimeout below. The Esc-on- |
| 146 |
// QE-canvas gesture hits this path: the canvas's editable holds |
| 147 |
// focus, Esc clears `selected`, hover-bar's subscriber re-renders |
| 148 |
// the bar synchronously, then React commits the unmount in a |
| 149 |
// later microtask — by setTimeout(0) time the editable is gone |
| 150 |
// but the bar is fresh. The body-focus branch below would |
| 151 |
// otherwise tear that fresh bar down. |
| 152 |
const target = e.target; |
| 153 |
setTimeout(() => { |
| 154 |
if (getSession?.()) return; |
| 155 |
// Image-picker / modal popovers steal focus on mount. |
| 156 |
if ( |
| 157 |
document.querySelector( |
| 158 |
'.extendify-quick-edit-image-menu, .extendify-quick-edit-floating-bar', |
| 159 |
) |
| 160 |
) { |
| 161 |
return; |
| 162 |
} |
| 163 |
if (target && !document.body.contains(target)) return; |
| 164 |
const active = document.activeElement; |
| 165 |
if (!active || active === document.body) { |
| 166 |
hideBar(); |
| 167 |
return; |
| 168 |
} |
| 169 |
if (findTagged(active)) return; |
| 170 |
hideBar(); |
| 171 |
}, 0); |
| 172 |
}; |
| 173 |
|
| 174 |
onActivate = (e) => { |
| 175 |
if (e.key !== 'Enter' && e.key !== ' ') return; |
| 176 |
if (getSession?.()) return; |
| 177 |
const el = findTagged(e.target); |
| 178 |
if (!el) return; |
| 179 |
// Skip activation when the keystroke came from a child with |
| 180 |
// its own action (e.g. a link inside a nav item). |
| 181 |
if (e.target !== el) return; |
| 182 |
const target = resolveTarget(el); |
| 183 |
if (!target?.blockType) return; |
| 184 |
e.preventDefault(); |
| 185 |
const { quickEditable, aiAvailable } = pillContextFor(target); |
| 186 |
// Quick-editable blocks open the inline editor (picker types need the |
| 187 |
// bar mounted first so the dropdown can anchor to it). |
| 188 |
if (quickEditable) { |
| 189 |
showBar(el); |
| 190 |
editTarget(target); |
| 191 |
return; |
| 192 |
} |
| 193 |
// Ask-AI-only blocks (group / columns / media-text) have no editor to |
| 194 |
// open; route Enter straight to the agent like the Ask AI pill does. |
| 195 |
if (aiAvailable) { |
| 196 |
askAiTarget(el); |
| 197 |
return; |
| 198 |
} |
| 199 |
showBar(el); |
| 200 |
}; |
| 201 |
|
| 202 |
document.addEventListener('focusin', onFocusIn, true); |
| 203 |
document.addEventListener('focusout', onFocusOut, true); |
| 204 |
document.addEventListener('keydown', onActivate, true); |
| 205 |
}; |
| 206 |
|
| 207 |
export const detachKeyboardEntry = () => { |
| 208 |
if (!attached) return; |
| 209 |
attached = false; |
| 210 |
document.removeEventListener('focusin', onFocusIn, true); |
| 211 |
document.removeEventListener('focusout', onFocusOut, true); |
| 212 |
document.removeEventListener('keydown', onActivate, true); |
| 213 |
onFocusIn = null; |
| 214 |
onFocusOut = null; |
| 215 |
onActivate = null; |
| 216 |
undecorate(); |
| 217 |
}; |
| 218 |
|