| 1 |
import { patchVariantClasses } from '@shared/lib/variant-classes'; |
| 2 |
|
| 3 |
const POST_ATTR = 'data-extendify-agent-block-id'; |
| 4 |
const PART_ATTR = 'data-extendify-part-block-id'; |
| 5 |
const PRODUCT_ID_ATTR = 'data-extendify-quick-edit-product-id'; |
| 6 |
const PRODUCT_FIELD_ATTR = 'data-extendify-quick-edit-product-field'; |
| 7 |
const NAV_REF_ATTR = 'data-extendify-quick-edit-nav-ref'; |
| 8 |
const NAV_ITEM_INDEX_ATTR = 'data-extendify-quick-edit-nav-item-index'; |
| 9 |
const WPFORM_ID_ATTR = 'data-extendify-quick-edit-wpform-id'; |
| 10 |
const WPFORM_FIELD_ID_ATTR = 'data-extendify-quick-edit-wpform-field-id'; |
| 11 |
const MEDIATEXT_MEDIA_ATTR = 'data-extendify-quick-edit-mediatext-media'; |
| 12 |
|
| 13 |
const findWpFormId = (el) => { |
| 14 |
let n = el.parentElement; |
| 15 |
while (n) { |
| 16 |
const fid = n.getAttribute?.(WPFORM_ID_ATTR); |
| 17 |
if (fid) return Number(fid); |
| 18 |
n = n.parentElement; |
| 19 |
} |
| 20 |
return null; |
| 21 |
}; |
| 22 |
|
| 23 |
// Returns null for inline navigations (items as innerBlocks of the parent |
| 24 |
// post/template-part); only ref-based navs (separate wp_navigation CPT) match. |
| 25 |
const findNavContext = (el) => { |
| 26 |
const itemIndexStr = el.getAttribute(NAV_ITEM_INDEX_ATTR); |
| 27 |
if (itemIndexStr === null) return null; |
| 28 |
let n = el.parentElement; |
| 29 |
while (n) { |
| 30 |
const ref = n.getAttribute?.(NAV_REF_ATTR); |
| 31 |
if (ref) { |
| 32 |
return { |
| 33 |
navPostId: Number(ref), |
| 34 |
itemIndex: Number(itemIndexStr), |
| 35 |
}; |
| 36 |
} |
| 37 |
n = n.parentElement; |
| 38 |
} |
| 39 |
return null; |
| 40 |
}; |
| 41 |
|
| 42 |
const DYNAMIC_BASES = ['is-style-ext-preset', 'is-style-outline']; |
| 43 |
|
| 44 |
export const resolveTarget = (node) => { |
| 45 |
let el = node; |
| 46 |
while (el && el.nodeType === 1) { |
| 47 |
// WPForms fields and products both take priority over the block-id |
| 48 |
// walk — their tagged elements also carry the agent's block-id and |
| 49 |
// would otherwise resolve to the wrong handler. |
| 50 |
const wpfFieldId = el.getAttribute(WPFORM_FIELD_ID_ATTR); |
| 51 |
if (wpfFieldId) { |
| 52 |
const formId = findWpFormId(el); |
| 53 |
if (formId) { |
| 54 |
return { |
| 55 |
el, |
| 56 |
blockType: 'wpforms:field', |
| 57 |
formId, |
| 58 |
fieldId: Number(wpfFieldId), |
| 59 |
source: { |
| 60 |
kind: 'wpforms', |
| 61 |
formId, |
| 62 |
fieldId: Number(wpfFieldId), |
| 63 |
}, |
| 64 |
}; |
| 65 |
} |
| 66 |
} |
| 67 |
const productId = el.getAttribute(PRODUCT_ID_ATTR); |
| 68 |
const productField = el.getAttribute(PRODUCT_FIELD_ATTR); |
| 69 |
if (productId && productField) { |
| 70 |
return { |
| 71 |
el, |
| 72 |
productId: Number(productId), |
| 73 |
productField, |
| 74 |
// `product:<field>` keeps the synthetic blockType out of the core/* namespace. |
| 75 |
blockType: `product:${productField}`, |
| 76 |
source: { kind: 'product', id: Number(productId) }, |
| 77 |
}; |
| 78 |
} |
| 79 |
// media-text's image is a block attribute, not a child block, so its |
| 80 |
// <figure> is tagged on its own (MediaTextTagger). The figure sits |
| 81 |
// INSIDE the wrapper that carries the block id — resolve up to that |
| 82 |
// wrapper for the save, but keep the figure as `mediaEl` for the |
| 83 |
// picker's read + positioning. blockName is the real save target; |
| 84 |
// blockType stays synthetic so the picker gate routes it to ImagePicker. |
| 85 |
if (el.getAttribute(MEDIATEXT_MEDIA_ATTR)) { |
| 86 |
const wrapper = el.closest( |
| 87 |
`.wp-block-media-text[${POST_ATTR}], .wp-block-media-text[${PART_ATTR}]`, |
| 88 |
); |
| 89 |
if (!wrapper) return null; |
| 90 |
const postId = wrapper.getAttribute(POST_ATTR); |
| 91 |
if (postId) { |
| 92 |
return { |
| 93 |
el: wrapper, |
| 94 |
mediaEl: el, |
| 95 |
blockId: Number(postId), |
| 96 |
blockType: 'core/media-text:image', |
| 97 |
blockName: 'core/media-text', |
| 98 |
source: window.extQuickEditData?.context?.currentSource ?? null, |
| 99 |
}; |
| 100 |
} |
| 101 |
const partId = wrapper.getAttribute(PART_ATTR); |
| 102 |
const partSlug = wrapper.getAttribute('data-extendify-part-slug') || ''; |
| 103 |
return { |
| 104 |
el: wrapper, |
| 105 |
mediaEl: el, |
| 106 |
blockId: Number(partId), |
| 107 |
blockType: 'core/media-text:image', |
| 108 |
blockName: 'core/media-text', |
| 109 |
source: { kind: 'template-part', partSlug }, |
| 110 |
}; |
| 111 |
} |
| 112 |
const postId = el.getAttribute(POST_ATTR); |
| 113 |
if (postId) { |
| 114 |
return { |
| 115 |
el, |
| 116 |
blockId: Number(postId), |
| 117 |
blockType: detectBlockType(el), |
| 118 |
source: window.extQuickEditData?.context?.currentSource ?? null, |
| 119 |
}; |
| 120 |
} |
| 121 |
const partId = el.getAttribute(PART_ATTR); |
| 122 |
if (partId) { |
| 123 |
const partSlug = el.getAttribute('data-extendify-part-slug') || ''; |
| 124 |
const blockType = detectBlockType(el); |
| 125 |
// Ref-based nav items live in a separate wp_navigation post, |
| 126 |
// out of reach of /quick-edit/save's findBlock walk. Route |
| 127 |
// them through /quick-edit/wp-navigation instead. Inline nav |
| 128 |
// items fall through to the standard template-part save path. |
| 129 |
if ( |
| 130 |
blockType === 'core/navigation-link' || |
| 131 |
blockType === 'core/navigation-submenu' |
| 132 |
) { |
| 133 |
const navCtx = findNavContext(el); |
| 134 |
if (navCtx) { |
| 135 |
return { |
| 136 |
el, |
| 137 |
blockType, |
| 138 |
navPostId: navCtx.navPostId, |
| 139 |
itemIndex: navCtx.itemIndex, |
| 140 |
source: { |
| 141 |
kind: 'wp-navigation', |
| 142 |
id: navCtx.navPostId, |
| 143 |
itemIndex: navCtx.itemIndex, |
| 144 |
}, |
| 145 |
}; |
| 146 |
} |
| 147 |
} |
| 148 |
return { |
| 149 |
el, |
| 150 |
blockId: Number(partId), |
| 151 |
blockType, |
| 152 |
source: { kind: 'template-part', partSlug }, |
| 153 |
}; |
| 154 |
} |
| 155 |
el = el.parentElement; |
| 156 |
} |
| 157 |
return null; |
| 158 |
}; |
| 159 |
|
| 160 |
// Dynamic-content / wrapper blocks that don't make sense to surface as |
| 161 |
// editable on the front end. detectBlockType returns null when any of |
| 162 |
// these classes is present so selection bails before a pill ever renders. |
| 163 |
const KNOWN_UNSUPPORTED = new Set([ |
| 164 |
'wp-block-post-title', |
| 165 |
'wp-block-post-author', |
| 166 |
'wp-block-post-date', |
| 167 |
'wp-block-post-terms', |
| 168 |
// core/navigation wraps individual links; clicks route to the child link instead. |
| 169 |
'wp-block-navigation', |
| 170 |
]); |
| 171 |
|
| 172 |
// Class-name suffixes that look like wp-block-X but aren't a block type |
| 173 |
// (BEM children, layout helpers). Excluding them keeps the generic |
| 174 |
// "first wp-block-* class wins" derivation from inventing core/foo__bar. |
| 175 |
const WP_BLOCK_CLASS_BLOCKLIST = /__|^wp-block-(post|theme|root|preset)-/; |
| 176 |
|
| 177 |
// A third-party block `acme/testimonial` renders as `wp-block-acme-testimonial`, |
| 178 |
// structurally indistinguishable from a core slug like `media-text`. Without a |
| 179 |
// known-core check, `wp-block-acme-testimonial` would derive the fabricated type |
| 180 |
// `core/acme-testimonial` — a lie that can route a click to the wrong editor or |
| 181 |
// mislead the server's type guard. So emit `core/<slug>` only for a recognized |
| 182 |
// core slug; an unrecognized slug returns null → unsupported (Ask-AI-only), |
| 183 |
// which is fail-safe. A core block missing from this list degrades the same way, |
| 184 |
// so staleness costs an Ask-AI-only fallback, never a wrong write. |
| 185 |
const CORE_BLOCK_SLUGS = new Set([ |
| 186 |
'paragraph', |
| 187 |
'heading', |
| 188 |
'list', |
| 189 |
'list-item', |
| 190 |
'quote', |
| 191 |
'pullquote', |
| 192 |
'code', |
| 193 |
'preformatted', |
| 194 |
'verse', |
| 195 |
'details', |
| 196 |
'footnotes', |
| 197 |
'table', |
| 198 |
'table-of-contents', |
| 199 |
'image', |
| 200 |
'gallery', |
| 201 |
'audio', |
| 202 |
'video', |
| 203 |
'file', |
| 204 |
'cover', |
| 205 |
'media-text', |
| 206 |
'embed', |
| 207 |
'buttons', |
| 208 |
'button', |
| 209 |
'columns', |
| 210 |
'column', |
| 211 |
'group', |
| 212 |
'separator', |
| 213 |
'spacer', |
| 214 |
'more', |
| 215 |
'nextpage', |
| 216 |
'social-links', |
| 217 |
'social-link', |
| 218 |
'search', |
| 219 |
'html', |
| 220 |
'shortcode', |
| 221 |
'page-list', |
| 222 |
'page-list-item', |
| 223 |
'navigation-link', |
| 224 |
'navigation-submenu', |
| 225 |
'home-link', |
| 226 |
'loginout', |
| 227 |
'site-logo', |
| 228 |
'site-title', |
| 229 |
'site-tagline', |
| 230 |
'archives', |
| 231 |
'calendar', |
| 232 |
'categories', |
| 233 |
'latest-posts', |
| 234 |
'latest-comments', |
| 235 |
'rss', |
| 236 |
'tag-cloud', |
| 237 |
'avatar', |
| 238 |
'read-more', |
| 239 |
'term-description', |
| 240 |
]); |
| 241 |
|
| 242 |
const detectBlockType = (el) => { |
| 243 |
let firstWpBlockClass = null; |
| 244 |
for (const cls of el.classList) { |
| 245 |
if (KNOWN_UNSUPPORTED.has(cls)) return null; |
| 246 |
if (!firstWpBlockClass && cls.startsWith('wp-block-')) { |
| 247 |
if (!WP_BLOCK_CLASS_BLOCKLIST.test(cls)) firstWpBlockClass = cls; |
| 248 |
} |
| 249 |
} |
| 250 |
// Nav links/submenus render as <li class="wp-block-navigation-item"> and |
| 251 |
// the -link/-submenu classes aren't always present. |
| 252 |
if ( |
| 253 |
el.classList.contains('wp-block-navigation-item') || |
| 254 |
el.classList.contains('wp-block-navigation-link') || |
| 255 |
el.classList.contains('wp-block-navigation-submenu') |
| 256 |
) { |
| 257 |
return 'core/navigation-link'; |
| 258 |
} |
| 259 |
if (firstWpBlockClass) { |
| 260 |
const slug = firstWpBlockClass.slice('wp-block-'.length); |
| 261 |
return CORE_BLOCK_SLUGS.has(slug) ? `core/${slug}` : null; |
| 262 |
} |
| 263 |
// Tag-name fallback for tag-less <p> / <h1-6>. WP usually adds a |
| 264 |
// wp-block-* class, but older themes / hand-authored HTML may not. |
| 265 |
const tag = el.tagName.toLowerCase(); |
| 266 |
if (tag === 'p') return 'core/paragraph'; |
| 267 |
if (/^h[1-6]$/.test(tag)) return 'core/heading'; |
| 268 |
return null; |
| 269 |
}; |
| 270 |
|
| 271 |
const wpBlockAttributeClasses = |
| 272 |
/^has-([\w-]+-)?(background-color|color|font-size|gradient-background)$|^has-background$|^has-text-color$/; |
| 273 |
|
| 274 |
// Trailing instance number on a block-style-variation class, e.g. the `--3` in |
| 275 |
// is-style-ext-preset--button--soft-1--button-1--3. |
| 276 |
const variantInstanceClass = /--\d+$/; |
| 277 |
|
| 278 |
// Without this, a save render that omits the numbered instance class silently |
| 279 |
// drops the variation's CSS (e.g. a button's preset radius) until a full reload. |
| 280 |
const carryVariantInstanceClasses = (liveEl, newEl, bases) => { |
| 281 |
const isInstance = (cls) => |
| 282 |
variantInstanceClass.test(cls) && bases.some((b) => cls.startsWith(b)); |
| 283 |
|
| 284 |
const instanceByBase = new Map(); |
| 285 |
for (const el of [liveEl, ...liveEl.querySelectorAll('*')]) { |
| 286 |
for (const cls of el.classList) { |
| 287 |
if (isInstance(cls)) { |
| 288 |
instanceByBase.set(cls.replace(variantInstanceClass, ''), cls); |
| 289 |
} |
| 290 |
} |
| 291 |
} |
| 292 |
if (!instanceByBase.size) return; |
| 293 |
|
| 294 |
for (const el of [newEl, ...newEl.querySelectorAll('*')]) { |
| 295 |
const classes = [...el.classList]; |
| 296 |
if (classes.some(isInstance)) continue; |
| 297 |
for (const cls of classes) { |
| 298 |
const instance = instanceByBase.get(cls); |
| 299 |
if (instance) el.classList.add(instance); |
| 300 |
} |
| 301 |
} |
| 302 |
}; |
| 303 |
|
| 304 |
export const splice = (liveEl, renderedHtml) => { |
| 305 |
if (!liveEl || !renderedHtml) return null; |
| 306 |
|
| 307 |
const patched = patchVariantClasses( |
| 308 |
renderedHtml, |
| 309 |
liveEl.cloneNode(true), |
| 310 |
DYNAMIC_BASES, |
| 311 |
); |
| 312 |
|
| 313 |
const template = document.createElement('template'); |
| 314 |
template.innerHTML = patched || '<div style="display:none"></div>'; |
| 315 |
const newEl = template.content.firstElementChild; |
| 316 |
if (!newEl) return null; |
| 317 |
|
| 318 |
// Carry classes derived from inline attrs.style that don't survive |
| 319 |
// a single-block re-render (text/bg colors, font sizes). |
| 320 |
const newClasses = new Set(newEl.classList); |
| 321 |
liveEl.classList.forEach((cls) => { |
| 322 |
if (newClasses.has(cls)) return; |
| 323 |
if (!wpBlockAttributeClasses.test(cls)) return; |
| 324 |
newEl.classList.add(cls); |
| 325 |
}); |
| 326 |
|
| 327 |
carryVariantInstanceClasses(liveEl, newEl, DYNAMIC_BASES); |
| 328 |
|
| 329 |
// ext-animate--on starts at opacity:0; theme JS only runs on page |
| 330 |
// load, so a freshly spliced node would be invisible. |
| 331 |
for (const node of [newEl, ...newEl.querySelectorAll('.ext-animate--on')]) { |
| 332 |
node.classList.remove('ext-animate--on'); |
| 333 |
} |
| 334 |
|
| 335 |
// The block is re-rendered in an isolated save scope, so renderedHtml |
| 336 |
// carries that scope's own identity tags (data-extendify-agent-block-id=1). |
| 337 |
// Drop them before carrying the live element's real identity over — |
| 338 |
// otherwise a template-part block, whose live element is tagged only with |
| 339 |
// data-extendify-part-block-id, keeps the stray agent tag too, and |
| 340 |
// resolveTarget (which checks the agent tag before the part tag) |
| 341 |
// resolves the re-edit to the wrong (post) block. |
| 342 |
for (const attr of newEl.getAttributeNames()) { |
| 343 |
if (attr.startsWith('data-extendify-')) newEl.removeAttribute(attr); |
| 344 |
} |
| 345 |
for (const attr of liveEl.getAttributeNames()) { |
| 346 |
if (!attr.startsWith('data-extendify-')) continue; |
| 347 |
newEl.setAttribute(attr, liveEl.getAttribute(attr)); |
| 348 |
} |
| 349 |
|
| 350 |
liveEl.parentNode?.replaceChild(newEl, liveEl); |
| 351 |
return newEl; |
| 352 |
}; |
| 353 |
|