PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / QuickEdit / lib / rich-text-color.js

rich-text-color.js in Extendify 3.1.5, at src/QuickEdit/lib/rich-text-color.js

241 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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