| 1 |
import { BLOCK_ID_SEL, blockIdOf } from './block-el'; |
| 2 |
import { detectBlockType } from './block-type'; |
| 3 |
import { readComputedStyles } from './computed-styles'; |
| 4 |
|
| 5 |
// Signal-less but edit targets ("swap the columns", "round the image") — dropped, they're unaddressable. |
| 6 |
const ALWAYS_KEPT_TYPES = new Set([ |
| 7 |
'core/column', |
| 8 |
'core/columns', |
| 9 |
'core/image', |
| 10 |
'core/cover', |
| 11 |
'core/site-logo', |
| 12 |
]); |
| 13 |
|
| 14 |
const isTransparent = (value) => |
| 15 |
!value || |
| 16 |
value === 'transparent' || |
| 17 |
value.replace(/\s/g, '') === 'rgba(0,0,0,0)'; |
| 18 |
|
| 19 |
// core/button renders text + background in an inner link, so read styles off the |
| 20 |
// link — only for buttons, or a container would report a nested button's styles. |
| 21 |
const styleSource = (el) => |
| 22 |
el.classList?.contains('wp-block-button') |
| 23 |
? (el.querySelector('.wp-block-button__link') ?? el) |
| 24 |
: el; |
| 25 |
|
| 26 |
// Narrow by appearance — a token like `tertiary` says nothing about being yellow. |
| 27 |
const renderedStyles = (el) => { |
| 28 |
const styles = readComputedStyles(styleSource(el)); |
| 29 |
if (styles && isTransparent(styles.backgroundColor)) |
| 30 |
delete styles.backgroundColor; |
| 31 |
return styles && Object.keys(styles).length ? styles : null; |
| 32 |
}; |
| 33 |
|
| 34 |
// "A bit smaller" needs the picture's rendered width; the wrapper's is the row's. |
| 35 |
const PICTURE_TYPES = new Set(['core/image', 'core/site-logo']); |
| 36 |
|
| 37 |
const pictureWidth = (el) => { |
| 38 |
const img = el.matches?.('img') ? el : el.querySelector?.('img'); |
| 39 |
if (!img) return null; |
| 40 |
const width = img.ownerDocument.defaultView.getComputedStyle(img).width; |
| 41 |
return width && width !== 'auto' && width !== '0px' ? width : null; |
| 42 |
}; |
| 43 |
|
| 44 |
// style.css lives in a scoped `wp-custom-css-*` rule, invisible in the block code. |
| 45 |
const customCss = (el) => { |
| 46 |
const cls = [...(el.classList ?? [])].find((c) => |
| 47 |
c.startsWith('wp-custom-css-'), |
| 48 |
); |
| 49 |
if (!cls) return null; |
| 50 |
for (const sheet of el.ownerDocument?.styleSheets ?? []) { |
| 51 |
let rules; |
| 52 |
try { |
| 53 |
rules = sheet.cssRules; |
| 54 |
} catch { |
| 55 |
continue; // cross-origin sheet — can't read |
| 56 |
} |
| 57 |
for (const rule of rules) { |
| 58 |
if (rule.selectorText?.includes(cls)) return rule.style?.cssText || null; |
| 59 |
} |
| 60 |
} |
| 61 |
return null; |
| 62 |
}; |
| 63 |
|
| 64 |
const PART_SLUG_ATTR = 'data-extendify-part-slug'; |
| 65 |
|
| 66 |
// One request carries one partSlug, so a nested part's blocks would resolve |
| 67 |
// against the wrong post. |
| 68 |
const partScope = (root) => { |
| 69 |
const rootSlug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null; |
| 70 |
return (el) => (el?.getAttribute?.(PART_SLUG_ATTR) ?? null) === rootSlug; |
| 71 |
}; |
| 72 |
|
| 73 |
// Stops at nested tagged blocks so a container's text never bleeds from its children. |
| 74 |
const ownSubtree = (el) => { |
| 75 |
const clone = el.cloneNode(true); |
| 76 |
for (const nested of clone.querySelectorAll(BLOCK_ID_SEL)) nested.remove(); |
| 77 |
return clone; |
| 78 |
}; |
| 79 |
|
| 80 |
const ownText = (clone) => { |
| 81 |
const readable = clone.cloneNode(true); |
| 82 |
for (const hidden of readable.querySelectorAll('[aria-hidden="true"]')) { |
| 83 |
hidden.remove(); |
| 84 |
} |
| 85 |
const text = (readable.textContent || '').replace(/\s+/g, ' ').trim(); |
| 86 |
return text.length > 80 ? `${text.slice(0, 80)}…` : text; |
| 87 |
}; |
| 88 |
|
| 89 |
const colorSlugs = (clone) => { |
| 90 |
const out = {}; |
| 91 |
const nodes = [clone, ...clone.querySelectorAll('*')]; |
| 92 |
for (const node of nodes) { |
| 93 |
for (const cls of node.classList ?? []) { |
| 94 |
const bg = cls.match(/^has-(.+)-background-color$/); |
| 95 |
if (bg) out.backgroundColor = bg[1]; |
| 96 |
else if (cls !== 'has-text-color') { |
| 97 |
const text = cls.match(/^has-(.+)-color$/); |
| 98 |
if (text) out.textColor = text[1]; |
| 99 |
} |
| 100 |
} |
| 101 |
} |
| 102 |
return out; |
| 103 |
}; |
| 104 |
|
| 105 |
// Menu and icon colours live on the container, never the link that was clicked. |
| 106 |
// detectBlockType refuses these types so a click can't select the wrapper. |
| 107 |
const STYLE_OWNERS = [ |
| 108 |
{ selector: '.wp-block-navigation', type: 'core/navigation' }, |
| 109 |
{ selector: '.wp-block-social-links', type: 'core/social-links' }, |
| 110 |
]; |
| 111 |
|
| 112 |
export const ownerAncestorFor = (root) => { |
| 113 |
if (!root?.parentElement) return null; |
| 114 |
const inScope = partScope(root); |
| 115 |
for (const { selector, type } of STYLE_OWNERS) { |
| 116 |
const el = root.parentElement.closest(selector); |
| 117 |
if (!el || !inScope(el)) continue; |
| 118 |
const blockId = blockIdOf(el); |
| 119 |
if (!blockId) continue; |
| 120 |
const styles = renderedStyles(el); |
| 121 |
return { |
| 122 |
blockId, |
| 123 |
type, |
| 124 |
...colorSlugs(ownSubtree(el)), |
| 125 |
...(styles && { styles }), |
| 126 |
}; |
| 127 |
} |
| 128 |
return null; |
| 129 |
}; |
| 130 |
|
| 131 |
// Per-block summary of the selection — enough context for the agent to pick |
| 132 |
// targets ("the red button"); schemas + full code are fetched after the pick. |
| 133 |
export const buildSubtreeManifest = (root) => { |
| 134 |
if (!root) return []; |
| 135 |
const manifest = []; |
| 136 |
const inScope = partScope(root); |
| 137 |
// A directly-selected block is its own (and only) target, so the root is included. |
| 138 |
const els = [root, ...root.querySelectorAll(BLOCK_ID_SEL)].filter(inScope); |
| 139 |
for (const [index, el] of els.entries()) { |
| 140 |
const blockId = blockIdOf(el); |
| 141 |
if (!blockId) continue; |
| 142 |
const type = detectBlockType(el); |
| 143 |
if (!type) continue; |
| 144 |
const clone = ownSubtree(el); |
| 145 |
const text = ownText(clone); |
| 146 |
const colors = colorSlugs(clone); |
| 147 |
// Drop signal-less descendants, never the root (baseline for relative edits). |
| 148 |
if ( |
| 149 |
index > 0 && |
| 150 |
!ALWAYS_KEPT_TYPES.has(type) && |
| 151 |
!text && |
| 152 |
!colors.backgroundColor && |
| 153 |
!colors.textColor |
| 154 |
) |
| 155 |
continue; |
| 156 |
const width = PICTURE_TYPES.has(type) ? pictureWidth(el) : null; |
| 157 |
const styles = width |
| 158 |
? { ...renderedStyles(el), width } |
| 159 |
: renderedStyles(el); |
| 160 |
const css = customCss(el); |
| 161 |
manifest.push({ |
| 162 |
blockId, |
| 163 |
type, |
| 164 |
...(text && { text }), |
| 165 |
...colors, |
| 166 |
...(styles && { styles }), |
| 167 |
...(css && { css }), |
| 168 |
}); |
| 169 |
} |
| 170 |
return manifest; |
| 171 |
}; |
| 172 |
|
| 173 |
// Tagged blocks directly beneath el, past any untagged wrappers. |
| 174 |
const childBlockEls = (el, inScope) => { |
| 175 |
const out = []; |
| 176 |
for (const child of el.children ?? []) { |
| 177 |
if (!inScope(child)) continue; |
| 178 |
if (blockIdOf(child)) out.push(child); |
| 179 |
else out.push(...childBlockEls(child, inScope)); |
| 180 |
} |
| 181 |
return out; |
| 182 |
}; |
| 183 |
|
| 184 |
// Keeps every tagged block; the flat manifest drops signal-less ones. |
| 185 |
const treeNodesFor = (el, inScope) => |
| 186 |
childBlockEls(el, inScope).flatMap((child) => { |
| 187 |
const type = detectBlockType(child); |
| 188 |
const children = treeNodesFor(child, inScope); |
| 189 |
const blockId = blockIdOf(child); |
| 190 |
if (!type || !blockId) return children; |
| 191 |
return [ |
| 192 |
{ |
| 193 |
blockId, |
| 194 |
type, |
| 195 |
...(children.length && { children }), |
| 196 |
}, |
| 197 |
]; |
| 198 |
}); |
| 199 |
|
| 200 |
// Nested block structure for the selection. |
| 201 |
export const buildSubtreeTree = (root) => |
| 202 |
root ? treeNodesFor({ children: [root] }, partScope(root)) : []; |
| 203 |
|