irccloud formatting helper 2

Utility to add formatting chars for IRCCloud

// ==UserScript==
// @name         irccloud formatting helper 2
// @namespace    http://github.com/hnOsmium0001
// @version      2.2
// @description  Utility to add formatting chars for IRCCloud
// @author       Steve Howard
// @author       hnOsmium0001
// @license      MIT
// @match        https://www.irccloud.com/*
// @grant        GM_addStyle
// ==/UserScript==

GM_addStyle(`
.userscriptIFH-messagePreview {
}
`);

/**
 * 
 * @param {string} str
 * @returns {boolean}
 */
function isIrcMessageCommand(str) {
    // All commands start with a single '/'
    // Messages that has a slash at the beginning start with '//' (which gets collapsed to a '/' when sending)
    return str.startsWith('/') && !str.startsWith('//');
}

/**
 * 
 * @param {string} str
 * @returns {boolean}
 */
function isIrcMessagePlain(str) {
    return !isIrcMessageCommand(str);
}

/**
 * Remove all elements after and including the start-th element in array.
 * Does not perform bound checks.
 * @template T
 * @param {Array<T>} array
 * @param {number} start
 */
function removeArrayTail(array, start) {
    const count = array.length - start;
    for (let i = 0; i < count; ++i) {
        array.pop();
    }
}

/**
 * 
 * @param {string} symbolText 
 * @returns {HTMLElement?}
 */
function makeElementForFormattingSymbol(symbolText) {
    switch (symbolText) {
        case '**': return document.createElement('b');
        case '*':
        case '_': return document.createElement('i');
        case '__': return document.createElement('u');
        case '~~': return document.createElement('del');
        default: return null;
    }
}

/**
 * 
 * @param {string} symbolText 
 * @returns {string?}
 */
function makeIrcCodeForFormattingSymbol(symbolText) {
    switch (symbolText) {
        case '**': return '\x02';
        case '*':
        case '_': return '\x1d';
        case '__': return '\x1f';
        case '~~': return '\x1e';
        default: return null;
    }
}

/**
 * 
 * @typedef {'text' | 'symbol'} TokenType
 */

/**
 * 
 * @typedef {Object} Token
 * @property {string} text
 * @property {TokenType} type
 * @property {number} index
 * @property {number | undefined} pairedSymbolIndex Used interally by parser.
 */

/**
 * 
 * @param {string} str
 * @returns {Token[]}
 */
function doFormatTokenization(str) {
    // Current non-symbol token in [anchor,cursor)
    let anchor = 0; // Range begin
    let cursor = 0; // Range end

    /** @type {Token[]} */
    let tokens = [];

    let isEscaping = false;

    /**
     * 
     * @param {string} text 
     */
    function appendToLastTextToken(text) {
        if (tokens.length >= 1) {
            const lastToken = tokens[tokens.length - 1];
            if (lastToken.type == 'text') {
                lastToken.text += text;
                return;
            }
        }

        // Can't append, insert a new text token
        tokens.push({
            text: text,
            type: 'text',
            index: tokens.length,
        });
    }

    function tryPushTextRange() {
        let myCursor = cursor;
        let myAnchor = anchor;
        // If we have an escape sequence, don't include the '\' before current symbol
        if (isEscaping) {
            myCursor -= 1;
        }
        if (myCursor - myAnchor > 0) {
            appendToLastTextToken(str.substring(myAnchor, myCursor));
        }
    }

    /**
     * 
     * @param {string} symbolText 
     */
    function tryPushSymbol(symbolText) {
        if (isEscaping) {
            isEscaping = false;
            appendToLastTextToken(symbolText);
        } else {
            tokens.push({
                text: symbolText,
                type: 'symbol',
                index: tokens.length,
            });
        }
    }

    while (cursor < str.length) {
        // The number after 'c' represents the number of lookahead characters
        let c0 = str[cursor + 0];
        let c1 = (cursor < (str.length - 1)) ? str[cursor + 1] : '\0';

        let advance;
        let matchedControl = false;

        if (c0 == '*') {
            if (c1 == '*') {
                // **text**
                advance = 2;
                matchedControl = true;;
                tryPushTextRange();
                tryPushSymbol('**');
            } else {
                // *text*
                advance = 1;
                matchedControl = true;
                tryPushTextRange();
                tryPushSymbol('*');
            }
        } else if (c0 == '_') {
            if (c1 == '_') {
                // __text__
                advance = 2;
                matchedControl = true;
                tryPushTextRange();
                tryPushSymbol('__');
            } else {
                // _text_
                advance = 1;
                matchedControl = true;
                tryPushTextRange();
                tryPushSymbol('_');
            }
        } else if (c0 == '~' && c1 == '~') {
            // ~~text~~
            advance = 2;
            matchedControl = true;
            tryPushTextRange();
            tryPushSymbol('~~');
        } else if (c0 == '\\') {
            // Input: text \\*symbol*
            // ^^^ results in the double slash gets treated as a single slash vvv
            // Output: text \<i>symbol</i>

            advance = 1;
            if (isEscaping) {
                isEscaping = false;

                // Start a new text chunk after this '\' character
                matchedControl = true;

                tryPushTextRange();
            } else {
                isEscaping = true;
            }
        } else {
            // We didn't match anything
            advance = 1;

            // Treat backslash as a normal character, if something like '\text' appeared
            if (isEscaping) {
                isEscaping = false;
            }
        }

        cursor += advance;
        if (matchedControl) {
            anchor = /* The updated */ cursor;
        }
    }

    tryPushTextRange();

    return tokens;
}

/**
 * 
 * @param {Token[]} tokens 
 */
function doFormatMatchTokens(tokens) {
    /** @type {Token[]} */
    let stack = [];

    for (let i = 0; i < tokens.length; ++i) {
        const token = tokens[i];

        if (token.type == 'symbol') {
            searchStack: {
                // Scan the stack for matching controls
                for (let i = stack.length - 1; i >= 0; --i) {
                    const stackFrame = stack[i];
                    if (stackFrame.text == token.text) {
                        // Case: found
                        // - Discard all controls after this one, they are unmatched, e.g. **text__** gives a bold 'text__'
                        // - This leaves the pairedSymbolIndex field as undefined, which implies that it's not consumed
                        removeArrayTail(stack, i);

                        stackFrame.pairedSymbolIndex = token.index;
                        token.pairedSymbolIndex = stackFrame.index;

                        break searchStack;
                    }
                }
            }

            // Case: not found
            // - Push symbol into stack
            stack.push(token);
        }
    }

    // NOTE: everything else in stack is also unpaired
}

/**
 * 
 * @param {string} str 
 * @returns {HTMLSpanElement}
 */
function formatMarkdownForHtml(str) {
    const tokens = doFormatTokenization(str);
    doFormatMatchTokens(tokens);

    const view = document.createElement('span');

    /** @type {(HTMLElement | Text)[]} */
    let nodeStack = [view];

    for (let i = 0; i < tokens.length; ++i) {
        const token = tokens[i];

        if (token.pairedSymbolIndex !== undefined) {
            // This is a paired symbol token
            if (token.pairedSymbolIndex < i) {
                // This is a closing symbol
                while (nodeStack[nodeStack.length - 1] instanceof Text) {
                    nodeStack.pop();
                }
                nodeStack.pop();
            } else {
                // This is an opening symbol
                const lastNode = nodeStack[nodeStack.length - 1];
                const element = makeElementForFormattingSymbol(token.text);
                nodeStack.push(element);
                lastNode.appendChild(element);
            }
        } else {
            // This is a text token, or an unpaired symbol token (which should be treated as text)
            const lastNode = nodeStack[nodeStack.length - 1];
            const node = document.createTextNode(token.text);

            lastNode.appendChild(node);
        }
    }

    return view;
}

/**
 * 
 * @param {string} str 
 * @returns {string}
 */
function formatMarkdownForIrc(str) {
    const tokens = doFormatTokenization(str);
    doFormatMatchTokens(tokens);

    let message = '';
    
    for (const token of tokens) {
        if (token.pairedSymbolIndex !== undefined) {
            message += makeIrcCodeForFormattingSymbol(token.text);
        } else {
            message += token.text;
        }
    }

    return message;
}

let gState = {
    _useMarkdown: true,
    /** @type {Array<(oldValue: boolean) => void>} */
    _useMarkdownListeners: [],

    get useMarkdown() {
        return this._useMarkdown;
    },
    set useMarkdown(newValue) {
        const oldValue = this._useMarkdown;
        this._useMarkdown = newValue;
        for (const listener of this._useMarkdownListeners) {
            listener(oldValue);
        }
    },

    get useMarkdownListeners() {
        return this._useMarkdownListeners;
    },

    get useMarkdownIndicator() {
        return this._useMarkdown ? 'M' : 'T';
    },
};

/**
 * 
 * @returns {HTMLDivElement}
 */
function createMessagePreview() {
    const o = document.createElement('div');
    o.classList.add('userscriptIFH-messagePreview');
    return o;
}

/**
 * 
 * @returns {HTMLDivElement}
 */
function createMarkdownCell() {
    const o = document.createElement('div');
    // Too lazy to write another class, just reuse the existing class for the emoji selector
    // NOTE: this won't break the emojicell finder algorithm below, because that code runs only ever once per buffer
    o.classList.add('emojicell');
    o.id = `userscriptIFH-markdowncell${cb().bid()}`;
    o.title = 'Current markdown state, "M" represents markdown, "T" represents plain text.';

    const visual = document.createElement('i');
    visual.classList.add('fa');
    visual.innerText = gState.useMarkdownIndicator;
    o.appendChild(visual);

    o.addEventListener('click', event => {
        // Stop IRCloud's event handler for .emojicell getting this click, triggering the emoji selection menu
        event.stopImmediatePropagation();
        gState.useMarkdown = !gState.useMarkdown;
        // ^^^ This will trigger the listener callback below:
    });
    gState.useMarkdownListeners.push(() => {
        visual.innerText = gState.useMarkdownIndicator;
    })

    return o;
}

/**
 * 
 * @param {HTMLElement} elm 
 */
function clearElementChildren(elm) {
    // Taken from https://stackoverflow.com/a/65413839
    elm.replaceChildren();
}

function bindInputControls() {
    if (cb() == null) {
        return;
    }

    // Maintained by IRCCloud, a separate one per buffer
    /** @type {HTMLTextAreaElement} */
    const inputBox = document.getElementById(`bufferInputView${cb().bid()}`);

    if (!inputBox.dataset.userscriptFormattingHelperRegistered) {
        inputBox.dataset.userscriptFormattingHelperRegistered = true;

        const previewBox = createMessagePreview();
        inputBox.after(previewBox);

        // TODO is there a less hacky way to do this?
        const cells = inputBox.parentElement.parentElement.parentElement;
        const emojiCell = cells.getElementsByClassName('emojicell')[0];
        const markdownCell = createMarkdownCell();
        emojiCell.before(markdownCell);

        const updatePreviewBoxCallback = () => {
            const msg = inputBox.value;
            if (gState.useMarkdown && isIrcMessagePlain(msg)) {
                clearElementChildren(previewBox);
                previewBox.appendChild(formatMarkdownForHtml(msg));
            } else {
                clearElementChildren(previewBox);
            }
        };

        inputBox.addEventListener('input', updatePreviewBoxCallback);
        gState.useMarkdownListeners.push(updatePreviewBoxCallback);

        inputBox.addEventListener('keydown', event => {
            const msg = inputBox.value;
            if (event.key === 'Enter' &&
                gState.useMarkdown && isIrcMessagePlain(msg)) {
                // Hijack the input box content to be what IRC should receive, just before it's sent by IRCCloud's logic
                inputBox.value = formatMarkdownForIrc(msg);
                clearElementChildren(previewBox);
            }
        });
    }
}

function init() {
}

(function checkSession() {
    // Taken from https://github.com/dogancelik/irccloud-sws/blob/6836cac008/src/send_with_style.user.js#L394-L406
    if (unsafeWindow.hasOwnProperty('SESSION')) {
        unsafeWindow.SESSION.bind('init', () => {
            init();

            // For the initially open channel
            bindInputControls();

            // For switching channels later (channel == "buffer")
            unsafeWindow.SESSION.buffers.on('doneSelected', () => {
                bindInputControls();
            });
        });
    } else {
        setTimeout(checkSession, 100);
    }
})();