您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Buttons to format description
// ==UserScript== // @name GGn Game Description Formatter // @namespace none // @version 1.5.1.003 // @description Buttons to format description // @author ingts // @grant GM_addStyle // @match https://gazellegames.net/upload.php* // @match https://gazellegames.net/torrents.php?action=editgroup&groupid=* // @require https://update.greasyfork.org/scripts/540511/1656390/GGn%20Formatters.js // ==/UserScript== // noinspection CssUnusedSymbol destructiveEditsEnabled = true //language=css GM_addStyle(` #description-formatter { display: flex; gap: 3%; align-items: center; margin: 5px 0; padding: 0 10px; > div > button { height: auto; white-space: nowrap; padding: 5px; } button { transition-property: background-color; transition-duration: 1s; } button:active { background-color: gainsboro; transition-duration: 0s; } section { display: grid; grid-template-columns: 1fr 3fr; column-gap: 15px; row-gap: 8px; } .formatter-buttons-row { display: flex; flex-wrap: wrap; gap: 3px; } } `) /** @type {HTMLTextAreaElement} */ const descInput = isEditPage ? document.querySelector("textarea[name='body']") : document.getElementById('album_desc') // language=HTML descInput.insertAdjacentHTML('afterend', ` <section id="description-formatter"> <div style="display:flex;flex-direction:column;gap: 8px;width: fit-content;"> <button type="button">Format All</button> <button type="button">Format About</button> <button type="button">Format SR</button> </div> <section> <div> <strong>Casing</strong> <div class="formatter-buttons-row"></div> </div> <div> <strong>BBCode</strong> <div class="formatter-buttons-row"></div> </div> <div> <strong>Headers</strong> <div class="formatter-buttons-row"></div> </div> <div> <strong>System Requirements</strong> <div class="formatter-buttons-row"></div> <div class="formatter-buttons-row" style="margin-top: 3px;"></div> </div> </section> </section>`) const main = document.getElementById('description-formatter') const mainButtons = main.querySelectorAll(':scope > div button') const title = isEditPage ? document.querySelector("#content > div > h2 > a").textContent : document.getElementById('title').value mainButtons[0].onclick = () => mainButtonClick(desc => formatAll(desc, title)) mainButtons[1].onclick = () => mainButtonClick(desc => { const common = formatDescCommon(desc) return formatAbout(common, title) }) mainButtons[2].onclick = () => mainButtonClick(desc => { const common = formatDescCommon(desc) return formatSysReqs(common) }) function mainButtonClick(func) { const desc = descInput.value descInput.value = func(desc) createUnformattedArea(descInput, desc) } /** * @param {ButtonProp[]} buttonProps * @param {Element} appendTo * @param {boolean?} replaceTextOnly * @param {boolean?} insertText * @param {boolean?} textAsTooltip */ function addButtons({ buttonProps, appendTo, replaceTextOnly, insertText, textAsTooltip }) { for (const buttonProp of buttonProps) { const button = document.createElement('button') button.textContent = buttonProp.label button.type = 'button' const tooltip = textAsTooltip ? buttonProp.text : buttonProp.tooltip if (tooltip) { $(button).tooltipster({ // content: `<pre style="width: 100%;margin: 0;">${tooltip}</pre>`, content: tooltip.replace(/\n/g, '<br>'), contentAsHTML: true, maxWidth: 450, }) } appendTo.append(button) button.onclick = () => { const selectionStart = descInput.selectionStart const selectionEnd = descInput.selectionEnd if (insertText) { const textToInsert = buttonProp.text const insertAtStart = buttonProp?.insertPosition === 'start' const insertAtEnd = buttonProp?.insertPosition === 'end' const currentText = descInput.value let newValue, newCaretPos if (insertAtStart || insertAtEnd) { newValue = insertAtStart ? textToInsert + currentText : currentText + textToInsert newCaretPos = insertAtStart ? textToInsert.length : newValue.length } else { const selectionStart = descInput.selectionStart const before = currentText.substring(0, selectionStart) const after = currentText.substring(selectionEnd) newValue = before + textToInsert + after newCaretPos = selectionStart + textToInsert.length } descInput.value = newValue descInput.selectionStart = newCaretPos descInput.selectionEnd = newCaretPos } else { if (buttonProp.selectionFunc && selectionStart !== descInput.selectionEnd) { // use selection const selectedText = descInput.value.substring(selectionStart, descInput.selectionEnd) const currentText = descInput.value const before = currentText.substring(0, selectionStart) const after = currentText.substring(descInput.selectionEnd) const replacement = buttonProp.selectionFunc(selectedText) descInput.value = before + replacement + after descInput.selectionStart = selectionStart descInput.selectionEnd = selectionStart } else { // use caret const result = getBBCodeOrLineAtCaret(replaceTextOnly, buttonProp.includeNewLine) const replacement = buttonProp.func(buttonProp.returnTextOnly ? result.textOnly : result.text) const caretOffset = replacement.length - (result.end - result.start) const newCaretPos = selectionStart + caretOffset + ((replaceTextOnly ? result.textOnly.length : result.text.length - result.openingTagsLength) - replacement.length) debugger descInput.value = result.replace(replacement) descInput.selectionStart = newCaretPos descInput.selectionEnd = newCaretPos } } descInput.focus() } } } /** @typedef {{ label: string, tooltip?: string, func?: (str: string) => string, selectionFunc?: (str: string) => string, text?: string, insertPosition?: 'start' | 'end', includeNewLine?: boolean, returnTextOnly?: boolean, }} ButtonProp */ /** @type {ButtonProp[]} */ const casingButtons = [ { label: "Sentence case", func: toSentenceCase, selectionFunc: (str) => caseConvertSelection(str, toSentenceCase), returnTextOnly: true, tooltip: "Sentence cases the selection. If the selection contains [*][b]text[/b], only the bold text will be converted", }, { label: "Title Case", func: formatTitle, selectionFunc: (str) => caseConvertSelection(str, formatTitle), returnTextOnly: true, tooltip: "Title cases the selection. If the selection contains [*][b]text[/b], only the bold text will be converted", }, { label: "Lowercase", func: (str) => str.toLowerCase(), selectionFunc: (str) => str.toLowerCase(), returnTextOnly: true, }, ] function caseConvertSelection(str, convertFunc) { const listItemsReplaced = str.replace(/\[\*]\[b](.*?)\[\/b]/g, (match, p1) => { return `[*][b]${convertFunc(p1)}[/b]` }) return str !== listItemsReplaced ? listItemsReplaced : convertFunc(str) } function headerToListItem(str) { str = toSentenceCase(removeBbcode(str)) return str.replace(/(.+?)([:.?!])?$/, (match, p1, p2) => `[*][b]${str.replace(p2, '')}[/b]${p2 ? p2 : ':'} `) } function joinLinesToPreviousListItem(str) { const lines = str.split('\n') const result = [] for (let line of lines) { line = line.trim() if (!line) continue if (line.startsWith('[*]')) { result.push(line) } else { if (result.length > 0) { result[result.length - 1] += (/[.?!]$/.test(line) ? ' ' : '. ') + line } } } return result.join('\n') } /** @type {ButtonProp[]} */ const bbCodeButtons = [ { func: (str) => str, selectionFunc: removeBbcode, label: "Unwrap", returnTextOnly: true, tooltip: "Unwraps all BBCode at caret or removes tags in the selection" }, { func: (str) => headerToListItem(str), selectionFunc: (str) => { let s = str.replace(/^\[\*]\[b].*?\[\/b]/gm, '') .replace(/\[align=center](.*?)\[\/align]\n/g, (_, p1) => headerToListItem(p1)) .replace(/\[b](.*?)\[\/b]\n/g, (_, p1) => headerToListItem(p1)) .replace(/(^.+[^.?!\s])\s*\n/g, (match, p1) => match.split(' ') > 10 ? match : headerToListItem(p1)) const fixedMulti = fixMultiLinesInLists(s) return joinLinesToPreviousListItem(fixedMulti) }, label: "Header to List Item", includeNewLine: true, returnTextOnly: true, tooltip: `Unwraps then convert to "[*][b]{text}[b]: ". Also sentence cases, removes colons and the new line. With selection, lines like "[align=center]...[/align]" and "[b]...[/b]" will be converted first, then lines without terminal punctuation and shorter than 10 words. Also only with selection, non list item lines are joined to the previous list item. Empty lines are removed` }, { func: (str) => `[*]${str}`, selectionFunc: (str) => str.split('\n').filter(line => line.trim() !== '') .map(line => line.startsWith('[*]') ? line : '[*]' + line).join('\n'), label: "To List Item", tooltip: `Unwraps then convert to "[*]{text}". Every line in the selection will be converted and empty lines are removed` }, { func: (str) => `[b]${str}[/b]`, label: "Bold", returnTextOnly: true, tooltip: `Unwraps then wraps with [b]`, }, ] /** @type {ButtonProp[]} */ const headerButtons = [ { label: "About the game", text: headersMap.get("aboutGame"), insertPosition: 'start' }, { label: "Features", text: headersMap.get("features"), }, { label: "Gameplay", text: "\n[align=center][b][u]Gameplay[/u][/b][/align]", }, { label: "Story", text: "\n[align=center][b][u]Story[/u][/b][/align]", }, { label: "Characters", text: "\n[align=center][b][u]Characters[/u][/b][/align]", }, ] /** @type {ButtonProp[]} */ const sysReqsButtons = [ { label: "Regular", text: `${headersMap.get("sysReqs")} [b]Minimum[/b]${headersMap.get("os")}${headersMap.get("processor")}${headersMap.get("memory")}${headersMap.get("graphics")}${headersMap.get("storage")} [b]Recommended[/b]${headersMap.get("os")}${headersMap.get("processor")}${headersMap.get("memory")}${headersMap.get("graphics")}${headersMap.get("storage")}[/quote]`, insertPosition: 'end', }, { label: "None", text: `${headersMap.get("sysReqs")}None provided[/quote]`, insertPosition: 'end', }, { label: "Empty", text: `${headersMap.get("sysReqs")}[/quote]`, insertPosition: 'end', }, { label: "Minimum", text: `${headersMap.get("minimumReqs")}` }, { label: "Recommended", text: `${headersMap.get("recommendedReqs")}` }, ] /** @type {ButtonProp[]} */ const sysReqsLineButtons = [ { label: "OS", text: `${headersMap.get("os")}` }, { label: "Processor", text: `${headersMap.get("processor")}` }, { label: "Memory", text: `${headersMap.get("memory")}` }, { label: "Storage", text: `${headersMap.get("storage")}` }, { label: "Graphics", text: `${headersMap.get("graphics")}` }, { label: "Sound Card", text: `${headersMap.get("soundcard")}` }, { label: "DirectX", text: `${headersMap.get("directX")}` }, { label: "Additional Notes", text: `${headersMap.get("additionalnotes")}` }, { label: "Other", text: `${headersMap.get("other")}` }, { label: "Network", text: `${headersMap.get("network")}` }, { label: "Drive", text: `${headersMap.get("drive")}` }, { label: "Controllers", text: `${headersMap.get("controllers")}` }, ] const buttonDivs = main.querySelectorAll('.formatter-buttons-row') addButtons({ buttonProps: casingButtons, appendTo: buttonDivs[0], replaceTextOnly: true, }) addButtons({ buttonProps: bbCodeButtons, appendTo: buttonDivs[1], }) addButtons({ buttonProps: headerButtons, appendTo: buttonDivs[2], insertText: true, textAsTooltip: true, }) addButtons({ buttonProps: sysReqsButtons, appendTo: buttonDivs[3], insertText: true, textAsTooltip: true, }) addButtons({ buttonProps: sysReqsLineButtons, appendTo: buttonDivs[4], insertText: true, textAsTooltip: true, }) // button to convert align to [*][b] /** * @param {boolean} textOnly * @param {boolean?} includeNewLine * @returns {{text: string, start: number, end: number, textOnly: string, openingTagsLength: number, replace: (str: string) => string}} */ function getBBCodeOrLineAtCaret(textOnly, includeNewLine) { const text = descInput.value const caretPos = descInput.selectionStart // Find the current line boundaries const beforeCaret = text.substring(0, caretPos) const afterCaret = text.substring(caretPos) const lastNewlineBefore = beforeCaret.lastIndexOf('\n') const nextNewlineAfter = afterCaret.indexOf('\n') const lineStart = lastNewlineBefore === -1 ? 0 : lastNewlineBefore + 1 const lineEnd = nextNewlineAfter === -1 ? text.length : caretPos + nextNewlineAfter const currentLine = text.substring(lineStart, lineEnd) const caretPosInLine = caretPos - lineStart // BBCode tag pattern - matches opening and closing tags const tagPattern = /\[(\/?[^\]]+)]/g const tags = [] // Find all tags in the current line for (const match of currentLine.matchAll(tagPattern)) { const match1 = match[1] if (match1 === '*' || match1 === '#') { continue } tags.push({ tag: match1, fullTag: match[0], start: match.index, end: match.index + match[0].length, isClosing: match1.startsWith('/') }) } // Build a stack to track nested tags const tagStack = [] const tagPairs = [] for (const tag of tags) { if (tag.isClosing) { // Find matching opening tag const tagName = tag.tag.substring(1) // Remove the '/' prefix for (let i = tagStack.length - 1; i >= 0; i--) { // Extract base tag name (before any = or space) const openingTagName = tagStack[i].tag.split(/[=\s]/)[0] if (openingTagName === tagName) { tagPairs.push({ opening: tagStack[i], closing: tag }) tagStack.splice(i, 1) break } } } else { tagStack.push(tag) } } // Find all tag pairs that contain the caret, sorted by nesting level const containingPairs = [] for (const pair of tagPairs) { const contentStart = pair.opening.end const contentEnd = pair.closing.start // Check if caret is within the content of this tag pair if (caretPosInLine >= contentStart && caretPosInLine <= contentEnd) { containingPairs.push({ ...pair, range: contentEnd - contentStart }) } } if (containingPairs.length === 0) { // No BBCode found, return the whole line return { text: currentLine, textOnly: currentLine, start: lineStart, end: lineEnd, openingTagsLength: 0, replace: function (newText) { const before = text.substring(0, lineStart) const after = text.substring(lineEnd + (includeNewLine ? 1 : 0)) return before + newText + after } } } // Sort by range (innermost first) containingPairs.sort((a, b) => a.range - b.range) // Find the outermost pair (contains all others) const outermostPair = containingPairs[containingPairs.length - 1] const innermostPair = containingPairs[0] const targetPair = textOnly ? containingPairs[0] : outermostPair const absoluteStart = textOnly ? lineStart + targetPair.opening.end : lineStart + outermostPair.opening.start const onlyHasBbCode = /^\[(?!\*]).*\[\/.*]$/.test(currentLine) const absoluteEnd = (textOnly ? lineStart + targetPair.closing.start : lineStart + outermostPair.closing.end) + (includeNewLine && onlyHasBbCode ? 1 : 0) const fullBBCode = currentLine.substring(outermostPair.opening.start, outermostPair.closing.end) const textContent = currentLine.substring(innermostPair.opening.end, innermostPair.closing.start) return { text: fullBBCode, textOnly: textContent, start: absoluteStart, end: absoluteEnd, openingTagsLength: containingPairs.reduce((length, pair) => length + pair.opening.fullTag.length, 0), replace: function (newText) { const before = text.substring(0, absoluteStart) const after = text.substring(absoluteEnd) return before + newText + after } } }