Universal Text Formatter

Apply Bold and Italic styles to any text field.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Universal Text Formatter
// @namespace    https://github.com/code-loyko/
// @version      1.2
// @description  Apply Bold and Italic styles to any text field.
// @author       Loyko
// @match        *://*/*
// @license      GPL-3.0-or-later
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Unicode offsets for Sans-Serif Mathematical Alphanumeric Symbols
    // Style Index: [0: Normal, 1: Bold, 2: Italic, 3: Bold-Italic]
    const UNICODE_STYLE_OFFSETS = {
        UPPERCASE: [0, 120211, 120263, 120315], // A-Z
        LOWERCASE: [0, 120205, 120257, 120309], // a-z
        DIGIT:     [0, 120764, 0, 120764]       // 0-9 (No italics available for digits)
    };

    /**
     * Identifies character base, category, and current style bitmask (0-3).
     */
    function getCharacterMetadata(charCodePoint) {
        for (let styleBitmask = 3; styleBitmask >= 1; styleBitmask--) {
            for (const [charCategory, categoryOffsets] of Object.entries(UNICODE_STYLE_OFFSETS)) {
                const baseCodePoint = charCodePoint - categoryOffsets[styleBitmask];
                const isMatchingCategory = (charCategory === 'UPPERCASE' && baseCodePoint >= 65 && baseCodePoint <= 90) ||
                                           (charCategory === 'LOWERCASE' && baseCodePoint >= 97 && baseCodePoint <= 122) ||
                                           (charCategory === 'DIGIT'     && baseCodePoint >= 48 && baseCodePoint <= 57);

                if (isMatchingCategory) return { baseCodePoint, charCategory, currentStyle: styleBitmask };
            }
        }

        // Standard ASCII Fallback
        if (charCodePoint >= 65 && charCodePoint <= 90) return { baseCodePoint: charCodePoint, charCategory: 'UPPERCASE', currentStyle: 0 };
        if (charCodePoint >= 97 && charCodePoint <= 122) return { baseCodePoint: charCodePoint, charCategory: 'LOWERCASE', currentStyle: 0 };
        if (charCodePoint >= 48 && charCodePoint <= 57) return { baseCodePoint: charCodePoint, charCategory: 'DIGIT', currentStyle: 0 };

        return null; // Accents, punctuation, etc.
    }

    /**
     * Returns a formatter function based on the global toggle state of the selected text.
     * Behavior matches standard text editors: if any selected char lacks the style, apply to all.
     */
    function prepareTextFormatter(concatenatedSelectedText, requestedStyleBitmask) {
        const characterCodePoints = [...concatenatedSelectedText];
        const metadataListForSelection = characterCodePoints.map(char => getCharacterMetadata(char.codePointAt(0)));
        const isApplyingStyle = metadataListForSelection.some(metadata => metadata && (metadata.currentStyle & requestedStyleBitmask) === 0);

        return (textSegmentToFormat) => {
            return [...textSegmentToFormat].map(char => {
                const metadata = getCharacterMetadata(char.codePointAt(0));
                if (!metadata || (metadata.charCategory === 'DIGIT' && requestedStyleBitmask === 2)) return char;

                const updatedStyleBitmask = isApplyingStyle ? (metadata.currentStyle | requestedStyleBitmask) : (metadata.currentStyle & ~requestedStyleBitmask);
                return String.fromCodePoint(metadata.baseCodePoint + UNICODE_STYLE_OFFSETS[metadata.charCategory][updatedStyleBitmask]);
            }).join('');
        };
    }

    window.addEventListener('keydown', (keyboardEvent) => {
        const isModifierKeyPressed = keyboardEvent.ctrlKey || keyboardEvent.metaKey;
        const requestedStyleBitmask = (keyboardEvent.key.toLowerCase() === 'b') ? 1 : (keyboardEvent.key.toLowerCase() === 'i') ? 2 : 0;

        if (!isModifierKeyPressed || !requestedStyleBitmask) return;

        const eventTargetElement = keyboardEvent.target;
        const isStandardInputOrTextarea = eventTargetElement.tagName === 'TEXTAREA' || eventTargetElement.tagName === 'INPUT';

        // --- STANDARD INPUTS (Textareas) ---
        if (isStandardInputOrTextarea) {
            const selectionStartIndex = eventTargetElement.selectionStart;
            const selectionEndIndex = eventTargetElement.selectionEnd;
            if (selectionStartIndex === selectionEndIndex) return;

            keyboardEvent.preventDefault();
            keyboardEvent.stopImmediatePropagation();

            const selectedText = eventTargetElement.value.substring(selectionStartIndex, selectionEndIndex);
            const formatSegment = prepareTextFormatter(selectedText, requestedStyleBitmask);
            const finalFormattedText = formatSegment(selectedText);

            // Textareas support execCommand for Undo/Redo history
            document.execCommand('insertText', false, finalFormattedText);
            eventTargetElement.setSelectionRange(selectionStartIndex, selectionStartIndex + finalFormattedText.length);
            return;
        }

        // --- RICH TEXT EDITORS (LinkedIn, Facebook, etc.) ---
        const currentWindowSelection = window.getSelection();
        if (!currentWindowSelection.rangeCount || currentWindowSelection.isCollapsed) return;

        const activeSelectionRange = currentWindowSelection.getRangeAt(0);
        const selectionAncestorContainer = activeSelectionRange.commonAncestorContainer.nodeType === Node.TEXT_NODE
            ? activeSelectionRange.commonAncestorContainer.parentNode
            : activeSelectionRange.commonAncestorContainer;

        // TreeWalker isolates strictly the text nodes to prevent destroying HTML structures like <p> or <br>
        const textNodeWalker = document.createTreeWalker(selectionAncestorContainer, NodeFilter.SHOW_TEXT);
        const textNodesInSelection = [];
        let traversedTextNode;

        while ((traversedTextNode = textNodeWalker.nextNode())) {
            if (activeSelectionRange.intersectsNode(traversedTextNode)) {
                const traversedNodeRange = document.createRange();
                traversedNodeRange.selectNodeContents(traversedTextNode);
                const clampedSelectionRange = activeSelectionRange.cloneRange();

                // Clamp the intersection specifically to the boundaries of the current node
                if (clampedSelectionRange.compareBoundaryPoints(Range.START_TO_START, traversedNodeRange) < 0) {
                    clampedSelectionRange.setStart(traversedNodeRange.startContainer, traversedNodeRange.startOffset);
                }
                if (clampedSelectionRange.compareBoundaryPoints(Range.END_TO_END, traversedNodeRange) > 0) {
                    clampedSelectionRange.setEnd(traversedNodeRange.endContainer, traversedNodeRange.endOffset);
                }

                if (!clampedSelectionRange.collapsed) {
                    textNodesInSelection.push({
                        targetTextNode: traversedTextNode,
                        nodeSelectionStart: clampedSelectionRange.startOffset,
                        nodeSelectionEnd: clampedSelectionRange.endOffset
                    });
                }
            }
        }

        if (!textNodesInSelection.length) return;

        keyboardEvent.preventDefault();
        keyboardEvent.stopImmediatePropagation();

        // Evaluate global toggle format based on all text nodes involved
        const concatenatedSelectedText = textNodesInSelection.map(info => info.targetTextNode.nodeValue.substring(info.nodeSelectionStart, info.nodeSelectionEnd)).join('');
        const formatSegment = prepareTextFormatter(concatenatedSelectedText, requestedStyleBitmask);

        let rangeStartNode = textNodesInSelection[0].targetTextNode;
        let rangeStartOffset = textNodesInSelection[0].nodeSelectionStart;
        let rangeEndNode = textNodesInSelection[textNodesInSelection.length - 1].targetTextNode;
        let rangeEndOffset = textNodesInSelection[textNodesInSelection.length - 1].nodeSelectionEnd;

        // DOM mutation
        textNodesInSelection.forEach((nodeSelectionInfo, index) => {
            const { targetTextNode, nodeSelectionStart, nodeSelectionEnd } = nodeSelectionInfo;
            const originalNodeContent = targetTextNode.nodeValue;
            const textToFormat = originalNodeContent.substring(nodeSelectionStart, nodeSelectionEnd);
            const formattedTextResult = formatSegment(textToFormat);

            targetTextNode.nodeValue = originalNodeContent.substring(0, nodeSelectionStart) + formattedTextResult + originalNodeContent.substring(nodeSelectionEnd);

            // Sync the cursor position if multi-unit characters (like emojis)
            if (index === textNodesInSelection.length - 1) {
                rangeEndOffset += (formattedTextResult.length - textToFormat.length);
            }
        });

        // Maintain partial history compatibility
        eventTargetElement.dispatchEvent(new InputEvent('input', {
            inputType: 'insertReplacementText',
            bubbles: true,
            cancelable: true
        }));

        // Restore exact selection
        currentWindowSelection.removeAllRanges();
        const newSelectionRange = document.createRange();
        newSelectionRange.setStart(rangeStartNode, rangeStartOffset);
        newSelectionRange.setEnd(rangeEndNode, rangeEndOffset);
        currentWindowSelection.addRange(newSelectionRange);

    }, true);
})();