您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); } })();