PluginProbe
Extendify / 3.1.2
Extendify v3.1.2
3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 0.7.0 All 126 releases
extendify / src / QuickEdit / lib / dom.js

dom.js in Extendify 3.1.2, at src/QuickEdit/lib/dom.js

353 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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