| 1 |
// Text-color and highlight share core/text-color; we read the active format |
| 2 |
// before writing so changing one doesn't clobber the other. |
| 3 |
import { select as dataSelect } from '@wordpress/data'; |
| 4 |
import { |
| 5 |
applyFormat, |
| 6 |
create, |
| 7 |
removeFormat, |
| 8 |
toHTMLString, |
| 9 |
} from '@wordpress/rich-text'; |
| 10 |
|
| 11 |
export const RICHTEXT_ATTR_BY_BLOCK = { |
| 12 |
'core/paragraph': 'content', |
| 13 |
'core/heading': 'content', |
| 14 |
'core/verse': 'content', |
| 15 |
'core/button': 'text', |
| 16 |
'core/pullquote': 'value', |
| 17 |
'core/code': 'content', |
| 18 |
'core/preformatted': 'content', |
| 19 |
}; |
| 20 |
|
| 21 |
function parseInlineStyle(str) { |
| 22 |
const out = {}; |
| 23 |
(str || '').split(';').forEach((pair) => { |
| 24 |
const i = pair.indexOf(':'); |
| 25 |
if (i > 0) { |
| 26 |
const k = pair.slice(0, i).trim().toLowerCase(); |
| 27 |
const v = pair.slice(i + 1).trim(); |
| 28 |
if (k && v) out[k] = v; |
| 29 |
} |
| 30 |
}); |
| 31 |
return out; |
| 32 |
} |
| 33 |
|
| 34 |
// Canonical key order so the saved markup is consistent regardless of which |
| 35 |
// color (text vs highlight) the user picked first. |
| 36 |
const STYLE_KEY_ORDER = ['color', 'background-color']; |
| 37 |
|
| 38 |
function serializeInlineStyle(obj) { |
| 39 |
const ordered = [ |
| 40 |
...STYLE_KEY_ORDER.filter((k) => k in obj), |
| 41 |
...Object.keys(obj).filter((k) => !STYLE_KEY_ORDER.includes(k)), |
| 42 |
]; |
| 43 |
return ordered.map((k) => `${k}:${obj[k]}`).join(';'); |
| 44 |
} |
| 45 |
|
| 46 |
// Read the existing core/text-color inline-style property at one position. |
| 47 |
// Returns undefined when there's no core/text-color format at that position |
| 48 |
// or when the format carries no value for the requested prop. |
| 49 |
function readColorPropAt(value, i, prop) { |
| 50 |
const list = value?.formats?.[i]; |
| 51 |
const fmt = list?.find((f) => f.type === 'core/text-color'); |
| 52 |
if (!fmt) return undefined; |
| 53 |
return parseInlineStyle(fmt.attributes?.style || '')[prop]; |
| 54 |
} |
| 55 |
|
| 56 |
// Split [start, end) into runs where the OTHER property's existing value |
| 57 |
// stays uniform. The caller writes one core/text-color format per run with |
| 58 |
// (newProp=newColor, otherProp=runValue), so picking text color on a range |
| 59 |
// that already has a sub-range bg preserves that bg only on its sub-range |
| 60 |
// — and vice versa. Returns `[{ from, to, otherValue }]`. |
| 61 |
function segmentByOtherProp(value, start, end, otherProp) { |
| 62 |
const runs = []; |
| 63 |
if (start >= end) return runs; |
| 64 |
let runStart = start; |
| 65 |
let runValue = readColorPropAt(value, start, otherProp); |
| 66 |
for (let i = start + 1; i < end; i++) { |
| 67 |
const v = readColorPropAt(value, i, otherProp); |
| 68 |
if (v !== runValue) { |
| 69 |
runs.push({ from: runStart, to: i, otherValue: runValue }); |
| 70 |
runStart = i; |
| 71 |
runValue = v; |
| 72 |
} |
| 73 |
} |
| 74 |
runs.push({ from: runStart, to: end, otherValue: runValue }); |
| 75 |
return runs; |
| 76 |
} |
| 77 |
|
| 78 |
// Snapshot the DOM selection so it can be restored after the popover steals focus. |
| 79 |
export function captureDomRichTextSelection() { |
| 80 |
const ds = window.getSelection?.(); |
| 81 |
if (!ds || !ds.rangeCount) return null; |
| 82 |
const range = ds.getRangeAt(0).cloneRange(); |
| 83 |
let anchor = range.startContainer; |
| 84 |
if (anchor && anchor.nodeType !== 1) anchor = anchor.parentElement; |
| 85 |
if (!anchor) return null; |
| 86 |
const rtEl = anchor.closest('[contenteditable="true"]'); |
| 87 |
if (!rtEl) return null; |
| 88 |
const blockEl = rtEl.closest('[data-block]'); |
| 89 |
if (!blockEl) return null; |
| 90 |
|
| 91 |
const charOffset = (node, off) => { |
| 92 |
if (node === rtEl) { |
| 93 |
let n = 0; |
| 94 |
for (let i = 0; i < off; i++) { |
| 95 |
const child = rtEl.childNodes[i]; |
| 96 |
if (child) n += (child.textContent || '').length; |
| 97 |
} |
| 98 |
return n; |
| 99 |
} |
| 100 |
let total = 0; |
| 101 |
const walker = document.createTreeWalker(rtEl, NodeFilter.SHOW_TEXT); |
| 102 |
let cur = walker.nextNode(); |
| 103 |
while (cur) { |
| 104 |
if (cur === node) return total + off; |
| 105 |
total += cur.textContent.length; |
| 106 |
cur = walker.nextNode(); |
| 107 |
} |
| 108 |
return total; |
| 109 |
}; |
| 110 |
|
| 111 |
return { |
| 112 |
clientId: blockEl.dataset.block, |
| 113 |
startOffset: charOffset(range.startContainer, range.startOffset), |
| 114 |
endOffset: charOffset(range.endContainer, range.endOffset), |
| 115 |
}; |
| 116 |
} |
| 117 |
|
| 118 |
function resolveSelectionFromSnap(snap) { |
| 119 |
if (!snap) return null; |
| 120 |
let clientId, attrKey, startOffset, endOffset, block; |
| 121 |
let wholeBlockFallback = false; |
| 122 |
|
| 123 |
if ( |
| 124 |
snap.sel?.start?.attributeKey && |
| 125 |
snap.sel.end && |
| 126 |
snap.sel.end.clientId === snap.sel.start.clientId && |
| 127 |
snap.sel.end.attributeKey === snap.sel.start.attributeKey && |
| 128 |
snap.sel.start.offset !== snap.sel.end.offset |
| 129 |
) { |
| 130 |
clientId = snap.sel.start.clientId; |
| 131 |
attrKey = snap.sel.start.attributeKey; |
| 132 |
startOffset = Math.min(snap.sel.start.offset, snap.sel.end.offset); |
| 133 |
endOffset = Math.max(snap.sel.start.offset, snap.sel.end.offset); |
| 134 |
block = snap.sel.block; |
| 135 |
} else if (snap.dom && snap.dom.startOffset !== snap.dom.endOffset) { |
| 136 |
clientId = snap.dom.clientId; |
| 137 |
const editorSelect = dataSelect('core/block-editor'); |
| 138 |
block = snap.sel?.block || editorSelect?.getBlock(clientId); |
| 139 |
if (!block) return null; |
| 140 |
attrKey = RICHTEXT_ATTR_BY_BLOCK[block.name] || 'content'; |
| 141 |
startOffset = Math.min(snap.dom.startOffset, snap.dom.endOffset); |
| 142 |
endOffset = Math.max(snap.dom.startOffset, snap.dom.endOffset); |
| 143 |
} else { |
| 144 |
// No range: fall back to the active block's full RichText so the color |
| 145 |
// buttons still work when the user clicked in without dragging. |
| 146 |
const editorSelect = dataSelect('core/block-editor'); |
| 147 |
if (!editorSelect) return null; |
| 148 |
clientId = |
| 149 |
snap.sel?.start?.clientId || |
| 150 |
snap.dom?.clientId || |
| 151 |
editorSelect.getSelectedBlockClientId() || |
| 152 |
(editorSelect.getBlockOrder() || [])[0]; |
| 153 |
if (!clientId) return null; |
| 154 |
block = snap.sel?.block || editorSelect.getBlock(clientId); |
| 155 |
if (!block) return null; |
| 156 |
attrKey = RICHTEXT_ATTR_BY_BLOCK[block.name] || 'content'; |
| 157 |
startOffset = 0; |
| 158 |
endOffset = -1; |
| 159 |
wholeBlockFallback = true; |
| 160 |
} |
| 161 |
|
| 162 |
const raw = block.attributes[attrKey]; |
| 163 |
let value; |
| 164 |
if (typeof raw === 'string') { |
| 165 |
value = create({ html: raw }); |
| 166 |
} else if (raw && typeof raw === 'object' && typeof raw.text === 'string') { |
| 167 |
value = { |
| 168 |
text: raw.text, |
| 169 |
formats: Array.isArray(raw.formats) ? raw.formats.slice() : [], |
| 170 |
replacements: Array.isArray(raw.replacements) |
| 171 |
? raw.replacements.slice() |
| 172 |
: [], |
| 173 |
}; |
| 174 |
} else { |
| 175 |
return null; |
| 176 |
} |
| 177 |
|
| 178 |
const textLen = (value.text || '').length; |
| 179 |
if (wholeBlockFallback) { |
| 180 |
startOffset = 0; |
| 181 |
endOffset = textLen; |
| 182 |
} else { |
| 183 |
startOffset = Math.max(0, Math.min(startOffset, textLen)); |
| 184 |
endOffset = Math.max(0, Math.min(endOffset, textLen)); |
| 185 |
} |
| 186 |
if (startOffset === endOffset) return null; |
| 187 |
|
| 188 |
return { clientId, attrKey, startOffset, endOffset, block, value }; |
| 189 |
} |
| 190 |
|
| 191 |
// `dispatch` MUST come from the inline editor's React-context registry — |
| 192 |
// BlockEditorProvider creates a sub-registry that the outer wp.data doesn't see. |
| 193 |
export function applyColorFormat(snap, kind, color, dispatch) { |
| 194 |
const sel = resolveSelectionFromSnap(snap); |
| 195 |
if (!sel) return; |
| 196 |
const { clientId, attrKey, startOffset, endOffset, value } = sel; |
| 197 |
|
| 198 |
const targetProp = kind === 'text' ? 'color' : 'background-color'; |
| 199 |
const otherProp = kind === 'text' ? 'background-color' : 'color'; |
| 200 |
|
| 201 |
// Per-segment write: walk the range in runs where the OTHER property |
| 202 |
// is uniform, emit one core/text-color format per run carrying |
| 203 |
// (target=color, other=runValue). The pick the user explicitly made |
| 204 |
// is uniform across the selection by design; the property they DIDN'T |
| 205 |
// touch is preserved per-position so a pre-existing sub-range value |
| 206 |
// (e.g. bg=red on one word) survives unrelated text-color picks. |
| 207 |
const runs = segmentByOtherProp(value, startOffset, endOffset, otherProp); |
| 208 |
let next = removeFormat(value, 'core/text-color', startOffset, endOffset); |
| 209 |
for (const { from, to, otherValue } of runs) { |
| 210 |
const styles = {}; |
| 211 |
if (color) styles[targetProp] = color; |
| 212 |
if (otherValue !== undefined) styles[otherProp] = otherValue; |
| 213 |
|
| 214 |
// `<mark>` (core/text-color's wrapper) has a default yellow |
| 215 |
// background; override it when only the text color is set so the |
| 216 |
// browser-default doesn't bleed through on the live view. |
| 217 |
if (styles.color && !styles['background-color']) { |
| 218 |
styles['background-color'] = 'transparent'; |
| 219 |
} else if (!styles.color && styles['background-color'] === 'transparent') { |
| 220 |
delete styles['background-color']; |
| 221 |
} |
| 222 |
|
| 223 |
const styleStr = serializeInlineStyle(styles); |
| 224 |
if (!styleStr) continue; |
| 225 |
next = applyFormat( |
| 226 |
next, |
| 227 |
{ type: 'core/text-color', attributes: { style: styleStr } }, |
| 228 |
from, |
| 229 |
to, |
| 230 |
); |
| 231 |
} |
| 232 |
|
| 233 |
dispatch.updateBlockAttributes(clientId, { |
| 234 |
[attrKey]: toHTMLString({ value: next }), |
| 235 |
}); |
| 236 |
|
| 237 |
window.requestAnimationFrame(() => { |
| 238 |
dispatch.selectionChange(clientId, attrKey, startOffset, endOffset); |
| 239 |
}); |
| 240 |
} |
| 241 |
|