|
<?php |
|
/** |
|
* Gutenberg: Link Style Dropdown in the Link Popover |
|
* |
|
* Adds a "Link Style" <select> to the Gutenberg link popover. When a style |
|
* is chosen, the script adds/removes classes on the <a> element and optionally |
|
* wraps the link's inner content in <span class="btn-text">…</span> to support |
|
* button-like styles. |
|
* |
|
* How to use: |
|
* - Drop this into a mu-plugin, small plugin, or Code Snippets (PHP), admin only. |
|
* - Adjust the VARIANTS list below to match your theme's class names. |
|
* |
|
* Notes: |
|
* - Runs only in the block editor via `enqueue_block_editor_assets`. |
|
* - Non-destructive: it only toggles classes from the known set. |
|
* - Works with the current link selection; disabled if no <a> is selected. |
|
*/ |
|
|
|
add_action('enqueue_block_editor_assets', function () { |
|
$handle = 'link-style-in-popover'; |
|
wp_register_script($handle, false, ['wp-dom-ready'], null, true); |
|
wp_enqueue_script($handle); |
|
|
|
$js = <<<'JS' |
|
(function(){ |
|
// ---- Configuration --------------------------------------------------------- |
|
// Define selectable variants. "classes" will be applied to the <a>. |
|
// Change titles and classes to match your design system. |
|
const VARIANTS = [ |
|
{ key: 'none', title: '— no style —', classes: '' }, |
|
{ key: 'btn-primary', title: 'Button Primary', classes: 'btn btn-primary-custom btn-arrow' }, |
|
{ key: 'btn-outline', title: 'Button Primary (Outline)', classes: 'btn btn-outline-primary-custom btn-arrow' }, |
|
{ key: 'btn-light', title: 'Button Light', classes: 'btn btn-light-custom btn-arrow' }, |
|
{ key: 'btn-outline-light', title: 'Button Light (Outline)', classes: 'btn btn-outline-light-custom btn-arrow' }, |
|
{ key: 'btn-link', title: 'Button Arrow', classes: 'btn btn-link btn-arrow' }, |
|
]; |
|
|
|
// Set of ALL individual classes used by all variants (for cleanup). |
|
const ALL_CLASSES = new Set(VARIANTS.flatMap(v => v.classes.split(/\s+/)).filter(Boolean)); |
|
|
|
// Optional inner wrapper for button-like styling: <a><span class="btn-text">…</span></a> |
|
const WRAP_CLASS = 'btn-text'; |
|
|
|
// ---- Utilities ------------------------------------------------------------- |
|
// Returns the currently selected <a> in the editor selection (or null). |
|
function selAnchor(){ |
|
const sel = window.getSelection && window.getSelection(); |
|
if (!sel || !sel.anchorNode) return null; |
|
const node = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentElement : sel.anchorNode; |
|
return node ? node.closest('a') : null; |
|
} |
|
|
|
// Ensure/unset a <span class="btn-text"> wrapper for direct children of the <a>. |
|
// We only wrap direct children (via :scope) to avoid touching nested content. |
|
function ensureTextWrapper(a, enable){ |
|
if (!a) return; |
|
let wrap = a.querySelector(':scope > span.' + WRAP_CLASS); |
|
|
|
if (enable) { |
|
if (wrap) return; // already wrapped |
|
wrap = document.createElement('span'); |
|
wrap.className = WRAP_CLASS; |
|
// Move ALL current children into the wrapper to preserve content structure. |
|
while (a.firstChild) wrap.appendChild(a.firstChild); |
|
a.appendChild(wrap); |
|
} else { |
|
if (!wrap) return; |
|
// Unwrap: move children back to <a> and remove the wrapper. |
|
while (wrap.firstChild) a.insertBefore(wrap.firstChild, wrap); |
|
wrap.remove(); |
|
} |
|
} |
|
|
|
// Apply a variant by key to the currently selected <a>. |
|
function applyVariant(key){ |
|
const a = selAnchor(); |
|
if (!a) return; |
|
|
|
// Remove any known classes (clean slate). |
|
[...ALL_CLASSES].forEach(c => a.classList.remove(c)); |
|
|
|
const v = VARIANTS.find(x => x.key === key); |
|
|
|
// Toggle wrapper depending on whether variant adds any classes. |
|
const enableWrapper = !!(v && v.classes && v.classes.trim()); |
|
ensureTextWrapper(a, enableWrapper); |
|
|
|
// Apply target classes. |
|
if (enableWrapper) { |
|
v.classes.split(/\s+/).filter(Boolean).forEach(c => a.classList.add(c)); |
|
} |
|
} |
|
|
|
// Detect the active variant for a given <a> by checking if all classes match. |
|
function detectActiveKey(a){ |
|
if (!a) return 'none'; |
|
for (const v of VARIANTS) { |
|
if (!v.classes) continue; |
|
const need = v.classes.split(/\s+/).filter(Boolean); |
|
if (need.length && need.every(cls => a.classList.contains(cls))) return v.key; |
|
} |
|
return 'none'; |
|
} |
|
|
|
// ---- UI Injection ---------------------------------------------------------- |
|
// We add the control once per link popover instance. |
|
const seen = new WeakSet(); |
|
const POPOVER_SELECTOR = '.block-editor-link-control'; |
|
|
|
function buildControl(root){ |
|
if (!root || seen.has(root)) return; |
|
seen.add(root); |
|
|
|
// Try common containers inside the link control popover to place our field. |
|
const attachTo = |
|
root.querySelector('.block-editor-link-control__settings') || |
|
root.querySelector('.block-editor-link-control__search-input') || |
|
root.querySelector('.components-popover__content') || |
|
root; |
|
|
|
// Avoid duplicates. |
|
if (attachTo.querySelector('.link-style-control')) return; |
|
|
|
// Field wrapper matches WP component structure for consistent spacing. |
|
const wrap = document.createElement('div'); |
|
wrap.className = 'link-style-control components-base-control'; |
|
wrap.style.marginTop = '8px'; |
|
|
|
const label = document.createElement('label'); |
|
label.className = 'components-base-control__label'; |
|
label.textContent = 'Link Style'; |
|
|
|
const select = document.createElement('select'); |
|
select.className = 'components-select-control__input'; |
|
select.ariaLabel = 'Link Style'; |
|
|
|
VARIANTS.forEach(v=>{ |
|
const opt = document.createElement('option'); |
|
opt.value = v.key; |
|
opt.textContent = v.title; |
|
select.appendChild(opt); |
|
}); |
|
|
|
// Initialize to current selection state. |
|
const a = selAnchor(); |
|
select.value = detectActiveKey(a); |
|
select.disabled = !a; |
|
|
|
// Apply on change. |
|
select.addEventListener('change', () => applyVariant(select.value)); |
|
|
|
wrap.appendChild(label); |
|
wrap.appendChild(select); |
|
attachTo.appendChild(wrap); |
|
} |
|
|
|
// Observe DOM mutations to detect when the link popover appears. |
|
const mo = new MutationObserver((mlist) => { |
|
mlist.forEach(m => { |
|
m.addedNodes.forEach(n => { |
|
if (!(n instanceof HTMLElement)) return; |
|
if (n.matches && n.matches(POPOVER_SELECTOR)) buildControl(n); |
|
n.querySelectorAll && n.querySelectorAll(POPOVER_SELECTOR).forEach(buildControl); |
|
}); |
|
}); |
|
}); |
|
mo.observe(document.body, { childList: true, subtree: true }); |
|
|
|
// Keep the select in sync with selection changes inside the editor. |
|
document.addEventListener('selectionchange', () => { |
|
const a = selAnchor(); |
|
document.querySelectorAll('.link-style-control .components-select-control__input').forEach(sel => { |
|
sel.value = detectActiveKey(a); |
|
sel.disabled = !a; |
|
}); |
|
}); |
|
})(); |
|
JS; |
|
wp_add_inline_script($handle, $js, 'after'); |
|
|
|
// Minimal styling to integrate nicely into the popover UI. |
|
$css = <<<CSS |
|
.link-style-control.components-base-control { padding-top: 4px; border-top: 1px solid rgba(0,0,0,.06); } |
|
.link-style-control .components-select-control__input { width: 100%; } |
|
CSS; |
|
wp_add_inline_style('wp-block-editor', $css); |
|
}); |