您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Moves the vote counts in neal.fun/internet-roadtrip from the top right panel to be alongside the arrows, on the wheel, and in the radio
// ==UserScript== // @name Internet Roadtrip - Combined Votes Counts UI // @description Moves the vote counts in neal.fun/internet-roadtrip from the top right panel to be alongside the arrows, on the wheel, and in the radio // @namespace me.netux.site/user-scripts/internet-roadtrip/combined-votes-counts-ui // @version 1.5.0 // @author netux // @license MIT // @match https://neal.fun/internet-roadtrip/* // @icon https://neal.fun/favicons/internet-roadtrip.png // @run-at document-start // @grant GM_addStyle // @grant GM.getValue // @grant GM.setValue // @require https://cdn.jsdelivr.net/npm/[email protected] // ==/UserScript== /* globals IRF */ (async () => { const CSS_PREFIX = `cvcui-`; const cssClass = (names) => (Array.isArray(names) ? names : [names]).map((name) => `${CSS_PREFIX}${name}`).join(' '); const cssProp = (name) => `--${CSS_PREFIX}${name}`; await IRF.dom.container; GM_addStyle(` .container { & .results { top: 50px; right: 10px; width: fit-content; min-width: 200px; padding: 7px 10px; &::after { /* annoying... */ pointer-events: none; } & .results-content { padding-bottom: 6px; display: none; } & .${cssClass('results-content-toggle-button')} { width: 100%; height: 0.6rem; margin-block: 0.3rem 0.1rem; background-image: url("https://www.svgrepo.com/show/257732/up-arrow.svg"); background-size: contain; background-position: center; background-repeat: no-repeat; cursor: pointer; display: block; } &.${cssClass('results-content-open')} { & .${cssClass('results-content-toggle-button')} { rotate: 180deg; } & .results-content { display: revert; } } } .${cssClass('vote-count')} { position: absolute; font-family: "Roboto", sans-serif; color: white; text-shadow: ${[[0, 1], [0, -1], [1, 0], [-1, 0]].map(([x, y]) => `${x}px ${y}px 2px black`).join(', ')}; pointer-events: none; white-space: nowrap; } & .options { cursor: pointer; & .${cssClass('vote-count')} { bottom: -0.4em; left: 0; width: 100%; text-align: center; font-size: 12px; } &:not(.${cssClass('reduce-arrow-motion')}) :is( .option .option-arrow, .option .${cssClass('vote-count')} ) { transition: translate 0.1s linear; translate: /* x: */ 0 /* y: */ calc( /* simple lerp(min, max, percentage) */ var(${cssProp('arrow-motion-offset-min')}) + ( var(${cssProp('arrow-motion-offset-max')}) - var(${cssProp('arrow-motion-offset-min')}) ) * var(${cssProp('vote-count-percentage')}) * -1 /* invert sign so negative offset values correspond to the arrow going down */ ); } } & .wheel-container { & .${cssClass('vote-count')} { top: 22%; left: 50%; translate: -50%; font-size: 20px; user-select: none; } } } @media (max-width: 900px) { .container { & .results { top: 41px; right: 5px; } } } `); const containerVDOM = await IRF.vdom.container; const resultsEl = await IRF.dom.results; const resultsVDOM = await IRF.vdom.results; const optionsContainerEl = await IRF.dom.options; const wheelContainerEl = await IRF.dom.wheel; const radioEl = await IRF.dom.radio; const mapSound = await IRF.vdom.map.then((map) => map.data.mapSound); // yoink const wheelHonkVotesEl = document.createElement('span'); const radioSeekVotesTextNode = document.createTextNode('0'); function ensureOptionVotesEl(optionEl) { let votesEl = optionEl._votesEl; if (!votesEl) { votesEl = document.createElement('span'); votesEl.className = cssClass('vote-count'); votesEl.textContent = `0 (0%)`; optionEl.appendChild(votesEl); optionEl._votesEl = votesEl; } return votesEl; } function updateVotes(votes) { const totalVotes = Object.values(votes).reduce((total, count) => total + count, 0); const optionEls = optionsContainerEl.querySelectorAll('.option'); for (const [voteStr, votesCount] of Object.entries(votes)) { const percentage = totalVotes !== 0 ? (votesCount / totalVotes) : 0; const percentageStr = `${Math.floor(percentage * 100)}`; switch (voteStr) { case "-2": { wheelHonkVotesEl.textContent = `${votesCount} (${percentageStr}%)`; break; } case "-1": { radioSeekVotesTextNode.textContent = votesCount; break; } default: { const voteIndex = parseInt(voteStr, 10); const optionEl = optionEls[voteIndex]; if (!optionEl) { continue; } const votesEl = ensureOptionVotesEl(optionEl); votesEl.textContent = `${votesCount} (${percentageStr}%)`; optionEl.style.setProperty(cssProp('vote-count-percentage'), percentage); } } } } { const { set: voteCountsSetter } = Object.getOwnPropertyDescriptor(resultsVDOM.state._props, 'voteCounts'); Object.defineProperty(resultsVDOM.state._props, 'voteCounts', { set(newVoteCounts) { updateVotes(newVoteCounts); return voteCountsSetter.call(this, newVoteCounts); }, configurable: true, enumerable: true, }); } const settings = { 'results-content-open': false, 'reduce-arrow-motion': false, 'arrow-motion-offset-min': -10, 'arrow-motion-offset-max': 10 }; for (const key in settings) { const value = await GM.getValue(key, settings[key]); settings[key] = value; } async function updateDomFromSettings() { optionsContainerEl.classList.toggle(cssClass('reduce-arrow-motion'), settings['reduce-arrow-motion']); optionsContainerEl.style.setProperty(cssProp('arrow-motion-offset-min'), `${settings['arrow-motion-offset-min']}px`); optionsContainerEl.style.setProperty(cssProp('arrow-motion-offset-max'), `${settings['arrow-motion-offset-max']}px`); resultsEl.classList.toggle(cssClass('results-content-open'), settings['results-content-open']); } updateDomFromSettings(); async function saveSettings() { for (const key in settings) { await GM.setValue(key, settings[key]); } } { const optionsContainerMutationObserver = new MutationObserver((records) => { for (const record of records) { if (record.type !== "childList") { continue; } for (const addedOptionEl of record.addedNodes) { if (!addedOptionEl.classList?.contains('option')) { continue; } ensureOptionVotesEl(addedOptionEl); } } }); optionsContainerMutationObserver.observe(optionsContainerEl, { childList: true }); const wheelClickArealEl = wheelContainerEl.querySelector('.wheel-click-area'); wheelHonkVotesEl.className = cssClass('vote-count'); wheelClickArealEl.appendChild(wheelHonkVotesEl); const radioSeekButtonLabelEl = radioEl.querySelector('.control-button .button-label'); radioSeekButtonLabelEl.append( document.createTextNode(' ('), radioSeekVotesTextNode, document.createTextNode(')'), ); const resultsContentToggleEl = document.createElement('div'); resultsContentToggleEl.className = cssClass('results-content-toggle-button'); resultsContentToggleEl.addEventListener('click', async () => { mapSound?.play(); settings['results-content-open'] = !settings['results-content-open']; await saveSettings(); updateDomFromSettings(); }); const resultsContentEl = resultsEl.querySelector('.results-content'); resultsContentEl.insertAdjacentElement('afterend', resultsContentToggleEl); } { const tabContentStyle = ` .${cssClass('settings-tab-content')} { & *, *::before, *::after { box-sizing: border-box; } & h3 { margin-block: 0.5rem 1rem; } & .${cssClass('field-group')} { margin-block: 0.5rem; display: flex; align-items: center; justify-content: space-between; & input:is(:not([type]), [type="text"], [type="number"]) { height: 1.5rem; margin: 0; color: white; background: transparent; border: 1px solid #848e95; text-align: right; font-size: 100%; border-radius: 5rem; } } } `; const tab = IRF.ui.panel.createTabFor( { ... GM.info, script: { ... GM.info.script, name: GM.info.script.name.replace('Internet Roadtrip - ', '') } }, { tabName: 'Combine Votes Counts UI', style: tabContentStyle, className: cssClass('settings-tab-content'), } ); { // FIXME(netux): IRF v0.4.1-beta has a bug where the tab styles may not be injected // So we inject them ourselves. const styleEl = document.createElement('style'); styleEl.textContent = tabContentStyle; tab.container.append(styleEl); } function makeHeading(text) { const headingEl = document.createElement('h3'); headingEl.textContent = text; return headingEl; } function makeFieldGroup({ id, label }, renderInput) { const fieldGroupEl = document.createElement('div'); fieldGroupEl.className = cssClass('field-group'); const labelEl = document.createElement('label'); labelEl.textContent = label; fieldGroupEl.appendChild(labelEl); const inputEl = renderInput({ id }); fieldGroupEl.appendChild(inputEl); return fieldGroupEl; } tab.container.append( makeHeading('Arrow Motion'), makeFieldGroup({ id: `${CSS_PREFIX}disable-arrow-motion`, label: 'Disable Motion' }, ({ id }) => { const inputEl = document.createElement('input'); inputEl.id = id; inputEl.type = 'checkbox'; inputEl.className = IRF.ui.panel.styles.toggle; inputEl.checked = settings['reduce-arrow-motion']; inputEl.addEventListener('change', async () => { settings['reduce-arrow-motion'] = inputEl.checked; await saveSettings(); updateDomFromSettings(); }); return inputEl; }), makeFieldGroup({ id: `${CSS_PREFIX}arrow-motion-offset-min`, label: 'Minimum Offset (pixels)' }, ({ id }) => { const inputEl = document.createElement('input'); inputEl.id = id; inputEl.type = 'number'; inputEl.value = settings['arrow-motion-offset-min']; inputEl.addEventListener('change', async () => { settings['arrow-motion-offset-min'] = inputEl.value; await saveSettings(); updateDomFromSettings(); }); return inputEl; }), makeFieldGroup({ id: `${CSS_PREFIX}arrow-motion-offset-max`, label: 'Maximum Offset (pixels)' }, ({ id }) => { const inputEl = document.createElement('input'); inputEl.id = id; inputEl.type = 'number'; inputEl.value = settings['arrow-motion-offset-max']; inputEl.addEventListener('change', async () => { settings['arrow-motion-offset-max'] = inputEl.value; await saveSettings(); updateDomFromSettings(); }); return inputEl; }), ); } })();