| @@ -1,9 +1,8 @@ | ||
| 1 | +import { BLOCK_ID_SEL, blockIdOf } from './block-el'; | |
| 1 | 2 | import { detectBlockType } from './block-type'; |
| 2 | 3 | import { readComputedStyles } from './computed-styles'; |
| 3 | 4 | |
| 4 | -const BLOCK_ID_ATTR = 'data-extendify-agent-block-id'; | |
| 5 | - | |
| 6 | 5 | // Signal-less but edit targets ("swap the columns", "round the image") — dropped, they're unaddressable. |
| 7 | 6 | const ALWAYS_KEPT_TYPES = new Set([ |
| 8 | 7 | 'core/column', |
| 9 | 8 | 'core/columns', |
| @@ -31,8 +30,18 @@ | ||
| 31 | 30 | delete styles.backgroundColor; |
| 32 | 31 | return styles && Object.keys(styles).length ? styles : null; |
| 33 | 32 | }; |
| 34 | 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 | + | |
| 35 | 44 | // style.css lives in a scoped `wp-custom-css-*` rule, invisible in the block code. |
| 36 | 45 | const customCss = (el) => { |
| 37 | 46 | const cls = [...(el.classList ?? [])].find((c) => |
| 38 | 47 | c.startsWith('wp-custom-css-'), |
| @@ -51,18 +60,30 @@ | ||
| 51 | 60 | } |
| 52 | 61 | return null; |
| 53 | 62 | }; |
| 54 | 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 | + | |
| 55 | 73 | // Stops at nested tagged blocks so a container's text never bleeds from its children. |
| 56 | 74 | const ownSubtree = (el) => { |
| 57 | 75 | const clone = el.cloneNode(true); |
| 58 | - for (const nested of clone.querySelectorAll(`[${BLOCK_ID_ATTR}]`)) | |
| 59 | - nested.remove(); | |
| 76 | + for (const nested of clone.querySelectorAll(BLOCK_ID_SEL)) nested.remove(); | |
| 60 | 77 | return clone; |
| 61 | 78 | }; |
| 62 | 79 | |
| 63 | 80 | const ownText = (clone) => { |
| 64 | - const text = (clone.textContent || '').replace(/\s+/g, ' ').trim(); | |
| 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(); | |
| 65 | 86 | return text.length > 80 ? `${text.slice(0, 80)}…` : text; |
| 66 | 87 | }; |
| 67 | 88 | |
| 68 | 89 | const colorSlugs = (clone) => { |
| @@ -80,17 +101,45 @@ | ||
| 80 | 101 | } |
| 81 | 102 | return out; |
| 82 | 103 | }; |
| 83 | 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 | + | |
| 84 | 131 | // Per-block summary of the selection — enough context for the agent to pick |
| 85 | 132 | // targets ("the red button"); schemas + full code are fetched after the pick. |
| 86 | 133 | export const buildSubtreeManifest = (root) => { |
| 87 | 134 | if (!root) return []; |
| 88 | 135 | const manifest = []; |
| 136 | + const inScope = partScope(root); | |
| 89 | 137 | // A directly-selected block is its own (and only) target, so the root is included. |
| 90 | - const els = [root, ...root.querySelectorAll(`[${BLOCK_ID_ATTR}]`)]; | |
| 138 | + const els = [root, ...root.querySelectorAll(BLOCK_ID_SEL)].filter(inScope); | |
| 91 | 139 | for (const [index, el] of els.entries()) { |
| 92 | - if (!el.getAttribute?.(BLOCK_ID_ATTR)) continue; | |
| 140 | + const blockId = blockIdOf(el); | |
| 141 | + if (!blockId) continue; | |
| 93 | 142 | const type = detectBlockType(el); |
| 94 | 143 | if (!type) continue; |
| 95 | 144 | const clone = ownSubtree(el); |
| 96 | 145 | const text = ownText(clone); |
| @@ -103,12 +152,15 @@ | ||
| 103 | 152 | !colors.backgroundColor && |
| 104 | 153 | !colors.textColor |
| 105 | 154 | ) |
| 106 | 155 | continue; |
| 107 | - const styles = renderedStyles(el); | |
| 156 | + const width = PICTURE_TYPES.has(type) ? pictureWidth(el) : null; | |
| 157 | + const styles = width | |
| 158 | + ? { ...renderedStyles(el), width } | |
| 159 | + : renderedStyles(el); | |
| 108 | 160 | const css = customCss(el); |
| 109 | 161 | manifest.push({ |
| 110 | - blockId: el.getAttribute(BLOCK_ID_ATTR), | |
| 162 | + blockId, | |
| 111 | 163 | type, |
| 112 | 164 | ...(text && { text }), |
| 113 | 165 | ...colors, |
| 114 | 166 | ...(styles && { styles }), |
| @@ -118,26 +170,28 @@ | ||
| 118 | 170 | return manifest; |
| 119 | 171 | }; |
| 120 | 172 | |
| 121 | 173 | // Tagged blocks directly beneath el, past any untagged wrappers. |
| 122 | -const childBlockEls = (el) => { | |
| 174 | +const childBlockEls = (el, inScope) => { | |
| 123 | 175 | const out = []; |
| 124 | 176 | for (const child of el.children ?? []) { |
| 125 | - if (child.getAttribute?.(BLOCK_ID_ATTR)) out.push(child); | |
| 126 | - else out.push(...childBlockEls(child)); | |
| 177 | + if (!inScope(child)) continue; | |
| 178 | + if (blockIdOf(child)) out.push(child); | |
| 179 | + else out.push(...childBlockEls(child, inScope)); | |
| 127 | 180 | } |
| 128 | 181 | return out; |
| 129 | 182 | }; |
| 130 | 183 | |
| 131 | 184 | // Keeps every tagged block; the flat manifest drops signal-less ones. |
| 132 | -const treeNodesFor = (el) => | |
| 133 | - childBlockEls(el).flatMap((child) => { | |
| 185 | +const treeNodesFor = (el, inScope) => | |
| 186 | + childBlockEls(el, inScope).flatMap((child) => { | |
| 134 | 187 | const type = detectBlockType(child); |
| 135 | - const children = treeNodesFor(child); | |
| 136 | - if (!type || !child.getAttribute?.(BLOCK_ID_ATTR)) return children; | |
| 188 | + const children = treeNodesFor(child, inScope); | |
| 189 | + const blockId = blockIdOf(child); | |
| 190 | + if (!type || !blockId) return children; | |
| 137 | 191 | return [ |
| 138 | 192 | { |
| 139 | - blockId: child.getAttribute(BLOCK_ID_ATTR), | |
| 193 | + blockId, | |
| 140 | 194 | type, |
| 141 | 195 | ...(children.length && { children }), |
| 142 | 196 | }, |
| 143 | 197 | ]; |
| @@ -144,5 +198,5 @@ | ||
| 144 | 198 | }); |
| 145 | 199 | |
| 146 | 200 | // Nested block structure for the selection. |
| 147 | 201 | export const buildSubtreeTree = (root) => |
| 148 | - root ? treeNodesFor({ children: [root] }) : []; | |
| 202 | + root ? treeNodesFor({ children: [root] }, partScope(root)) : []; | |