您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Buttons to format description
// ==UserScript== // @name GGn Game Description Formatter // @namespace none // @version 1.6.3 // @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/1669265/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>Functions</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 if (!buttonProp.func) return const result = getBBCodeOrLineAtCaret(replaceTextOnly, buttonProp.includeNewLines) 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', includeNewLines?: 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) => `[*][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 hasTerminalPunctuation(str) { return /[.?!,]$/.test(str) // comma is just so Join works with partial sentences } function joinLinesToPreviousListItem(str) { const lines = str.split('\n') const result = [] for (let line of lines) { if (!line.trim()) continue if (line.startsWith('[*]')) { result.push(line) } else { if (result.length < 1) continue const prevIndex = result.length - 1 const endsWithColonSpace = result[prevIndex].endsWith(': ') if (!endsWithColonSpace && !hasTerminalPunctuation(result[prevIndex])) result[prevIndex] += '.' result[prevIndex] += (endsWithColonSpace ? '' : ' ') + line } } const joined = result.join('\n') return hasTerminalPunctuation(joined) ? joined : joined + '.' } /** @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 .replaceAll('[*]', '') // .replace(/^\[\*]\[b].*?\[\/b](?:: )?/gm, '') forgot the reason for this .replace(/\[align=center](.*?)\[\/align]\n/g, (_, p1) => headerToListItem(p1)) .replace(/\[b](.*?)\[\/b](?:: )?\n/g, (_, p1) => headerToListItem(p1)) .replace(/(^.+[^.?!\s])\s*\n/g, (match, p1) => (p1.startsWith('[') || match.split(' ') > 10) ? match : headerToListItem(p1)) const fixedMulti = fixMultiLinesInLists(s) return joinLinesToPreviousListItem(fixedMulti) }, label: "Header to List Item", includeNewLines: 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, [*]s are removed and non list item lines are joined to the converted 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]`, }, { func: str => { str = str.trim() return hasTerminalPunctuation(str) ? str + ' ' : str + '. ' }, selectionFunc: str => { str = str.replaceAll('[*]', '').trim() const lines = str.split('\n') let result = lines[0] for (let i = 1; i < lines.length; i++) { const line = lines[i].trim() if (!line) continue const lastCharIndex = str.indexOf(line) + line.length - 1 const nextLine = lines[i + countLeadingEmptyLines(str.substring(lastCharIndex))] result += hasTerminalPunctuation(result) || /^[a-z]/.test(nextLine) ? ' ' : '. ' + line } return result.replace(/\s+$/, '') + (hasTerminalPunctuation(result) ? '' : '.') }, label: "Join", includeNewLines: true, tooltip: `Joins the next line to the current line and adds a full stop if needed. In selection, removes all [*] then joins lines to the first line`, }, ] /** @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?} includeNewLines * @returns {{text: string, start: number, end: number, textOnly: string, openingTagsLength: number, replace: (str: string) => string}} */ function getBBCodeOrLineAtCaret(textOnly, includeNewLines) { 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) return before + newText + getAfterText(lineEnd, includeNewLines) } } } // 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) 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) return before + newText + getAfterText(absoluteEnd, includeNewLines && onlyHasBbCode) } } function getAfterText(endPos, condition) { const after = text.substring(endPos) if (!condition) return after return text.substring(endPos + countLeadingEmptyLines(after)) } } function countLeadingEmptyLines(substr) { const lines = substr.split('\n') let newlinesUntilNotEmpty = 0 for (let i = 0; i < lines.length; i++) { if (lines[i].trim() === '') { newlinesUntilNotEmpty++ } else { break } } return newlinesUntilNotEmpty }