MediaWiki:Gadget-text-spacing.js
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google Chrome、Firefox、Microsoft Edge及Safari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
/**
* This gadget automatically adjust spacing between Chinese with English,
* with symbols and with numbers.
*
* @author [[User:Diskdance]] <https://w.wiki/5F6e>
* @license BSD-3-Clause
* @see https://github.com/diskdance/gadget-text-spacing
*/
// <nowiki>
(function () {
'use strict';
const pendingActions = new WeakMap();
// Optimization: lazily execute pending actions once an element is visible
const observer = new IntersectionObserver(onIntersection);
function onIntersection(entries) {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const element = entry.target;
observer.unobserve(element);
const callbacks = pendingActions.get(element);
if (callbacks === undefined) {
continue;
}
while (true) {
const callback = callbacks.shift(); // FIFO
if (callback === undefined) {
break;
}
callback(element);
}
}
}
function queueDomMutation(element, callback) {
var _a;
if (!pendingActions.has(element)) {
pendingActions.set(element, []);
}
(_a = pendingActions.get(element)) === null || _a === void 0 ? void 0 : _a.push(callback);
observer.observe(element);
}
function isInlineHTMLElement(node) {
return (node instanceof HTMLElement &&
window.getComputedStyle(node).display.includes('inline'));
}
function isTextNode(node) {
return node.nodeType === Node.TEXT_NODE;
}
function isVisible(element) {
const style = window.getComputedStyle(element);
return (style.display !== 'none' &&
!['hidden', 'collapse'].includes(style.visibility) &&
Number.parseFloat(style.opacity) > 0);
}
function getNodeText(node) {
return node instanceof HTMLElement ? node.innerText : node.data;
}
/**
* Split a string before an array of indexes.
*
* For example,
* ```
* splitAtIndexes('123456789', [3, 5, 7]);
* ```
* results in
* ```
* ['123', '45', '67', '89']
* ```
*
* Note that empty string are included:
* ```
* splitAtIndexes('123456789', [0, 9]);
* ```
* results in
* ```
* ['', '123456789', '']
* ```
*
* Indexes that are negative or greater than the length of the string are ignored.
*
* @param str string to split
* @param indexes indexes
* @returns splitted string fragments
*/
function splitAtIndexes(str, indexes) {
const result = [];
const normalizedIndexes = [
// Remove duplications and sort in ascending order
...new Set(indexes
.sort((a, b) => a - b)
.filter((i) => i >= 0 && i <= str.length)),
str.length,
];
for (let i = 0; i < normalizedIndexes.length; i++) {
const slice = str.slice(normalizedIndexes[i - 1], normalizedIndexes[i]);
result.push(slice);
}
return result;
}
const REGEX_RANGE_CHINESE = '(?:[\\u2E80-\\u2E99\\u2E9B-\\u2EF3\\u2F00-\\u2FD5\\u3005\\u3007\\u3021-\\u3029\\u3038-\\u303B\\u3400-\\u4DBF\\u4E00-\\u9FFF\\uF900-\\uFA6D\\uFA70-\\uFAD9]|\\uD81B[\\uDFE2\\uDFE3\\uDFF0\\uDFF1]|[\\uD840-\\uD868\\uD86A-\\uD86C\\uD86F-\\uD872\\uD874-\\uD879\\uD880-\\uD883][\\uDC00-\\uDFFF]|\\uD869[\\uDC00-\\uDEDF\\uDF00-\\uDFFF]|\\uD86D[\\uDC00-\\uDF38\\uDF40-\\uDFFF]|\\uD86E[\\uDC00-\\uDC1D\\uDC20-\\uDFFF]|\\uD873[\\uDC00-\\uDEA1\\uDEB0-\\uDFFF]|\\uD87A[\\uDC00-\\uDFE0]|\\uD87E[\\uDC00-\\uDE1D]|\\uD884[\\uDC00-\\uDF4A])';
const REGEX_RANGE_OTHER_LEFT = '[A-Za-z0-9@~%+=|±\\)}#$¥€£₤]';
const REGEX_RANGE_OTHER_RIGHT = '[A-Za-z0-9@~%+=|±\\({#$¥€£₤]';
const REGEX_STR_INTER_SCRIPT = `(?:(${REGEX_RANGE_CHINESE})(?=${REGEX_RANGE_OTHER_RIGHT})|(${REGEX_RANGE_OTHER_LEFT})(?=${REGEX_RANGE_CHINESE}))`;
const SPACE = '\u200A';
const WRAPPER_CLASS = 'gadget-space';
const SELECTOR_ALLOWED = [
'a', 'abbr', 'article', 'aside', 'b',
'bdi', 'big', 'blockquote', 'button',
'caption', 'center', 'cite', 'data',
'dd', 'del', 'details', 'dfn', 'div',
'dt', 'em', 'figcaption', 'footer',
'h1', 'h2', 'h3', 'h4', 'h5', 'header',
'i', 'ins', 'label', 'legend', 'li',
'main', 'mark', 'option', 'p', 'q',
'ruby', 's', 'section', 'small', 'span',
'strike', 'strong', 'sub', 'summary',
'sup', 'td', 'th', 'time', 'u',
];
const SELECTOR_BLOCKED = [
'code', 'kbd', 'pre', 'rp', 'rt',
'samp', 'textarea', 'var',
// Elements with this class are excluded
'.gadget-nospace',
// Editable elements
'[contenteditable="true"]',
// ACE editor content
'.ace_editor',
// Visual Editor (and 2017 Wikitext Editor) content & diff
'.ve-ui-surface',
'.ve-init-mw-diffPage-diff',
// Diff
'.diff-context',
'.diff-addedline',
'.diff-deletedline',
// Diff (inline mode)
'.mw-diff-inline-added',
'.mw-diff-inline-deleted',
'.mw-diff-inline-moved',
'.mw-diff-inline-changed',
'.mw-diff-inline-context',
];
// FIXME: Use :is() in the future once it has better browser compatibility
const SELECTOR = SELECTOR_ALLOWED
.map((allowed) => `${allowed}:not(${SELECTOR_BLOCKED
.flatMap((blocked) =>
// Not include itself if it is a tag selector
blocked[0].match(/[a-z]/i) ? `${blocked} *` : [blocked, `${blocked} *`])
.join(',')})`)
.join(',');
function getLeafElements(parent) {
const candidates = parent.querySelectorAll(SELECTOR);
const result = [];
if (parent.matches(SELECTOR)) {
result.push(parent);
}
for (const candidate of candidates) {
for (const childNode of candidate.childNodes) {
if (isTextNode(childNode)) {
result.push(candidate);
break;
}
}
}
return result;
}
function getNextVisibleSibling(node) {
let currentNode = node;
// Use loops rather than recursion for better performance
while (true) {
const candidate = currentNode.nextSibling;
if (candidate === null) {
const parent = currentNode.parentElement;
if (parent === null) {
// Parent is Document, so no visible sibling
return null;
}
// Bubble up to its parent and get its sibling
currentNode = parent;
continue;
}
if (!(candidate instanceof HTMLElement || candidate instanceof Text)) {
// Comments, SVGs, etc.: get its sibling as result
currentNode = candidate;
continue;
}
if (candidate instanceof HTMLElement) {
if (!isVisible(candidate)) {
// Invisible: recursively get this element's next sibling
currentNode = candidate;
continue;
}
if (!isInlineHTMLElement(candidate)) {
// Next sibling is not inline (at next line), so no siblings
return null;
}
}
if (candidate instanceof Text && candidate.data.trim() === '') {
// Skip empty Text nodes (e.g. line breaks)
currentNode = candidate;
continue;
}
return candidate;
}
}
function createSpacingWrapper(str) {
const span = document.createElement('span');
span.className = WRAPPER_CLASS;
span.textContent = str.slice(-1);
return [str.slice(0, -1), span];
}
function adjustSpacing(element) {
var _a;
// Freeze NodeList in advance
const childNodes = [...element.childNodes];
const textSpacingPosMap = new Map();
for (const child of childNodes) {
if (!(child instanceof Text)) {
continue;
}
const nextSibling = getNextVisibleSibling(child);
let testString = getNodeText(child);
if (nextSibling !== null) {
// Append first character to detect script intersection
testString += (_a = getNodeText(nextSibling)[0]) !== null && _a !== void 0 ? _a : '';
}
const indexes = [];
// Global regexps are stateful so do initialization in each loop
const regexTextNodeData = new RegExp(REGEX_STR_INTER_SCRIPT, 'g');
while (true) {
const match = regexTextNodeData.exec(testString);
if (match === null) {
break;
}
indexes.push(match.index + 1); // +1 to match script boundary
}
if (indexes.length === 0) {
// Optimization: skip further steps
// Also prevent unnecessary mutation, which will be detected by MutationObserver,
// resulting in infinite loops
continue;
}
textSpacingPosMap.set(child, indexes);
}
// Schedule DOM mutation to prevent forced reflows
queueDomMutation(element, () => {
for (const [node, indexes] of textSpacingPosMap) {
const text = node.data;
const fragments = splitAtIndexes(text, indexes);
const replacement = fragments
.slice(0, -1)
.flatMap((fragment) => createSpacingWrapper(fragment));
replacement.push(fragments.slice(-1)[0]);
// Optimization: prevent forced reflows
requestAnimationFrame(() => {
node.replaceWith(...replacement);
});
}
});
}
function addSpaceToString(str) {
const regex = new RegExp(REGEX_STR_INTER_SCRIPT, 'g');
return str.replace(regex, `$1$2${SPACE}`);
}
function run(element) {
const leaves = getLeafElements(element);
for (const leaf of leaves) {
adjustSpacing(leaf);
}
}
const mutationObserver = new MutationObserver((records) => {
for (const record of records) {
if (record.type !== 'childList') {
continue;
}
const nodes = [...record.addedNodes];
// Exclude mutations caused by adjustSpacing() to prevent infinite loops
// Typically they will contain nodes with class WRAPPER_CLASS
if (nodes.some((node) => node instanceof HTMLElement && node.classList.contains(WRAPPER_CLASS))) {
continue;
}
for (const node of nodes) {
if (node instanceof HTMLElement) {
run(node);
}
else if (node instanceof Text) {
const { parentElement } = node;
if (parentElement !== null) {
run(parentElement);
}
}
}
}
});
function main() {
document.title = addSpaceToString(document.title);
// Watch for added nodes
mutationObserver.observe(document.body, { subtree: true, childList: true });
run(document.body);
}
$(main);
})();
// </nowiki>