← All changes
|
src/Agent/workflows/block-selector/tools/find-blocks.js
+85
-28
3.1.5
→
trunk
View file →
| @@ -1,22 +1,50 @@ | ||
| 1 | +import { | |
| 2 | + blockIdOf, | |
| 3 | + parseScopedId, | |
| 4 | + resolveScopedId, | |
| 5 | + scopedBlockId, | |
| 6 | +} from '@agent/lib/block-el'; | |
| 1 | 7 | import { IGNORED_BLOCKS } from '@agent/lib/classify-block-edit'; |
| 2 | 8 | import { buildSubtreeManifest } from '@agent/lib/subtree-manifest'; |
| 9 | +import { DYNAMIC_BLOCK_TYPES } from '@quick-edit/lib/agent-gate'; | |
| 3 | 10 | |
| 4 | -// Mirrors TagBlocks::$ignored — a patch here would rewrite every loop item. | |
| 5 | -const LOOP_BLOCKS = new Set([ | |
| 6 | - 'core/query', | |
| 7 | - 'core/post-template', | |
| 8 | - 'core/post-content', | |
| 9 | - 'core/comments', | |
| 10 | - 'core/comment-template', | |
| 11 | - 'woocommerce/product-collection', | |
| 12 | - 'woocommerce/product-template', | |
| 13 | -]); | |
| 11 | +// Reachable once selected — the logo's image bridges to the site setting — but | |
| 12 | +// never worth offering unprompted, since it has a workflow of its own. | |
| 13 | +const OWN_WORKFLOW_BLOCKS = new Set(['core/site-logo']); | |
| 14 | 14 | |
| 15 | -const MAX_CANDIDATES = 25; | |
| 15 | +// Page candidates come first, so one shared budget leaves the header none. | |
| 16 | +const MAX_PAGE_CANDIDATES = 15; | |
| 17 | +const MAX_PART_CANDIDATES = 5; | |
| 16 | 18 | |
| 17 | -const BLOCK_ID_ATTR = 'data-extendify-agent-block-id'; | |
| 19 | +const PART_SLUG_ATTR = 'data-extendify-part-slug'; | |
| 20 | +const PART_LABEL_ATTR = 'data-extendify-part'; | |
| 18 | 21 | |
| 22 | +// One root sees only its own part, so the page root misses header and footer. | |
| 23 | +const scopeRoots = (pageRoot) => { | |
| 24 | + const roots = [pageRoot]; | |
| 25 | + for (const el of document.querySelectorAll(`[${PART_SLUG_ATTR}]`)) { | |
| 26 | + const slug = el.getAttribute(PART_SLUG_ATTR); | |
| 27 | + if (!slug) continue; | |
| 28 | + // A part's top-level blocks are siblings; unrooted, all but the first go unwalked. | |
| 29 | + const enclosing = el.parentElement?.closest( | |
| 30 | + `[${PART_SLUG_ATTR}="${CSS.escape(slug)}"]`, | |
| 31 | + ); | |
| 32 | + if (!enclosing) roots.push(el); | |
| 33 | + } | |
| 34 | + return roots; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const manifestFor = (root) => { | |
| 38 | + const slug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null; | |
| 39 | + const part = root?.getAttribute?.(PART_LABEL_ATTR) ?? null; | |
| 40 | + return buildSubtreeManifest(root).map((entry) => ({ | |
| 41 | + ...entry, | |
| 42 | + blockId: scopedBlockId(entry.blockId, slug), | |
| 43 | + ...(part && { part }), | |
| 44 | + })); | |
| 45 | +}; | |
| 46 | + | |
| 19 | 47 | // An item can't add a sibling, nor stand in for the whole set. |
| 20 | 48 | export const CONTAINER_TYPES = new Set([ |
| 21 | 49 | 'core/columns', |
| 22 | 50 | 'core/list', |
| @@ -23,18 +51,20 @@ | ||
| 23 | 51 | 'core/group', |
| 24 | 52 | ]); |
| 25 | 53 | |
| 26 | 54 | const addressable = (type) => |
| 27 | - !LOOP_BLOCKS.has(type) && !IGNORED_BLOCKS.has(type); | |
| 55 | + !DYNAMIC_BLOCK_TYPES.has(type) && | |
| 56 | + !IGNORED_BLOCKS.has(type) && | |
| 57 | + !OWN_WORKFLOW_BLOCKS.has(type); | |
| 28 | 58 | |
| 29 | 59 | const containersFor = (matches, byId) => { |
| 30 | 60 | const found = new Map(); |
| 31 | 61 | for (const { blockId } of matches) { |
| 32 | - let node = document.querySelector( | |
| 33 | - `[${BLOCK_ID_ATTR}="${CSS.escape(String(blockId))}"]`, | |
| 34 | - )?.parentElement; | |
| 62 | + let node = resolveScopedId(blockId)?.parentElement; | |
| 35 | 63 | while (node) { |
| 36 | - const id = node.getAttribute?.(BLOCK_ID_ATTR); | |
| 64 | + const slug = node.getAttribute?.(PART_SLUG_ATTR) ?? null; | |
| 65 | + const raw = blockIdOf(node); | |
| 66 | + const id = raw ? scopedBlockId(raw, slug) : null; | |
| 37 | 67 | const entry = id ? byId.get(id) : null; |
| 38 | 68 | if (entry && CONTAINER_TYPES.has(entry.type)) { |
| 39 | 69 | found.set(id, entry); |
| 40 | 70 | break; |
| @@ -44,24 +74,42 @@ | ||
| 44 | 74 | } |
| 45 | 75 | return [...found.values()]; |
| 46 | 76 | }; |
| 47 | 77 | |
| 48 | -const matchesText = (blockText, text) => | |
| 49 | - !text || (blockText ?? '').toLowerCase().includes(text.toLowerCase()); | |
| 78 | +// A search for "footer" arrives as text and would otherwise match nothing. | |
| 79 | +const matchesText = ({ text: blockText, part }, text) => { | |
| 80 | + if (!text) return true; | |
| 81 | + const needle = text.toLowerCase(); | |
| 82 | + if ((blockText ?? '').toLowerCase().includes(needle)) return true; | |
| 83 | + return Boolean(part) && needle.includes(part.toLowerCase()); | |
| 84 | +}; | |
| 50 | 85 | |
| 51 | 86 | const matchesType = (type, blockTypes) => |
| 52 | 87 | !blockTypes?.length || blockTypes.includes(type); |
| 53 | 88 | |
| 54 | -export default ({ blockTypes, text } = {}) => { | |
| 89 | +const matchesPart = (entry, part) => | |
| 90 | + !part || (entry.part ?? '').toLowerCase() === part.toLowerCase(); | |
| 91 | + | |
| 92 | +const capPerScope = (found) => { | |
| 93 | + const taken = new Map(); | |
| 94 | + return found.filter(({ blockId }) => { | |
| 95 | + const { partSlug } = parseScopedId(blockId); | |
| 96 | + const cap = partSlug ? MAX_PART_CANDIDATES : MAX_PAGE_CANDIDATES; | |
| 97 | + const used = taken.get(partSlug) ?? 0; | |
| 98 | + if (used >= cap) return false; | |
| 99 | + taken.set(partSlug, used + 1); | |
| 100 | + return true; | |
| 101 | + }); | |
| 102 | +}; | |
| 103 | + | |
| 104 | +export default ({ blockTypes, text, part } = {}) => { | |
| 55 | 105 | const root = document.querySelector('.wp-site-blocks') ?? document.body; |
| 56 | - const all = buildSubtreeManifest(root).filter(({ type }) => | |
| 57 | - addressable(type), | |
| 58 | - ); | |
| 106 | + const all = scopeRoots(root) | |
| 107 | + .flatMap(manifestFor) | |
| 108 | + .filter((entry) => addressable(entry.type) && matchesPart(entry, part)); | |
| 59 | 109 | const byId = new Map(all.map((entry) => [entry.blockId, entry])); |
| 60 | 110 | const byType = all.filter(({ type }) => matchesType(type, blockTypes)); |
| 61 | - const matches = byType.filter(({ text: blockText }) => | |
| 62 | - matchesText(blockText, text), | |
| 63 | - ); | |
| 111 | + const matches = byType.filter((entry) => matchesText(entry, text)); | |
| 64 | 112 | // "hero" is a position, not text: AND-ing it would match nothing. |
| 65 | 113 | const textIgnored = |
| 66 | 114 | Boolean(text) && |
| 67 | 115 | Boolean(blockTypes?.length) && |
| @@ -66,11 +114,19 @@ | ||
| 66 | 114 | Boolean(text) && |
| 67 | 115 | Boolean(blockTypes?.length) && |
| 68 | 116 | !matches.length && |
| 69 | 117 | byType.length > 0; |
| 70 | - const found = textIgnored ? byType : matches; | |
| 118 | + // core/navigation is unaddressable, so a menu search can match no type at all. | |
| 119 | + const byText = all.filter((entry) => matchesText(entry, text)); | |
| 120 | + const typeIgnored = | |
| 121 | + Boolean(text) && | |
| 122 | + Boolean(blockTypes?.length) && | |
| 123 | + !matches.length && | |
| 124 | + !byType.length && | |
| 125 | + byText.length > 0; | |
| 126 | + const found = textIgnored ? byType : typeIgnored ? byText : matches; | |
| 71 | 127 | // Cap first, or a long match list truncates the containers away. |
| 72 | - const shown = found.slice(0, MAX_CANDIDATES); | |
| 128 | + const shown = capPerScope(found); | |
| 73 | 129 | const containers = containersFor(shown, byId).filter( |
| 74 | 130 | (entry) => !shown.some(({ blockId }) => blockId === entry.blockId), |
| 75 | 131 | ); |
| 76 | 132 | return { |
| @@ -76,8 +132,9 @@ | ||
| 76 | 132 | return { |
| 77 | 133 | blockSearch: { |
| 78 | 134 | total: found.length, |
| 79 | 135 | ...(textIgnored && { ignoredText: text }), |
| 136 | + ...(typeIgnored && { ignoredBlockTypes: blockTypes }), | |
| 80 | 137 | candidates: [...shown, ...containers], |
| 81 | 138 | }, |
| 82 | 139 | }; |
| 83 | 140 | }; |