MediaWiki:Gadget-text-spacing.js

注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ 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>