// ==UserScript==
// @name Quick CYOA calculator
// @namespace http://tampermonkey.net/
// @version 0.9.3
// @description Overlay for quick CYOA playing
// @author agreg
// @license MIT
// @match https://cubari.moe/*
// @match https://imgur.com/*
// @match https://imgchest.com/*
// @match https://ibb.co/*
// @match https://funnyjunk.com/*/*
// @match https://www.reddit.com/r/*/comments/*
// @match https://old.reddit.com/r/*/comments/*
// @match https://www.reddit.com/media?*
// @match https://agregen.gitlab.io/cyoa-viewer/v1.html?*
// @match https://agregen.gitlab.io/cyoa-viewer/v2.html?*
// @match https://*/*.jpg
// @match https://*/*.jpeg
// @match https://*/*.png
// @match https://*/*.webp
// @match http://*/*.jpg
// @match http://*/*.jpeg
// @match http://*/*.png
// @match http://*/*.webp
// @match file://*/*.jpg
// @match file://*/*.jpeg
// @match file://*/*.png
// @match file://*/*.webp
// @require https://unpkg.com/[email protected]/dist/mreframe.js
// @require https://unpkg.com/lz-string/libs/lz-string.js
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
if (window != window.top) return; // embedded frame
const [HOST, URI] = [location.hostname, location.pathname];
const [CUBARI, IMGUR, IMAGECHEST, IMGBB, FUNNYJUNK, GITLAB] = ["cubari.moe", "imgur.com", "imgchest.com", "ibb.co", "funnyjunk.com", "agregen.gitlab.io"];
const REDDIT = /(www|old)\.reddit\.com/;
GM_registerMenuCommand("Toggle overlay", async function $toggle () {
const {reFrame: rf, reagent: r, util: {getIn, assoc, assocIn, dissoc, merge, keys, entries, dict, isDict, isArray}} = require('mreframe');
const $ = '-cyoaoverlay';
const [HEIGHT, WIDTH] = ["50vh", "max(300px, 33vw)"];
const COLORS = {white: "", red: 'red', green: 'lime', blue: 'royalblue'/*'cornflowerblue'*/, orange: 'orange', yellow: 'yellow', violet: 'blueviolet', purple: 'magenta', grey: 'grey'};
const GROUPING = {list: "", groups: 'groups', inline: 'inline'};
let _trim = s => `${s}`.replace(/\s+/g, ' ').trim();
let _cap = s => `${s}`.replace(/^./, c => c.toUpperCase());
let _mapVals = (o, f) => dict( entries(o).map(([k, v]) => [k, f(v)]) );
let _dissocIn = (o, path, k) => assocIn(o, path, dissoc(getIn(o, path), k));
let _sort = xs => xs.slice().sort(new Intl.Collator('en', {sensitivity: 'base'}).compare);
let _isEmpty = o => {for (let k in o) return false; return true};
let _isInt = Number.isInteger;
let _delta = (n, plus='+') => `${n < 0 ? '−' : plus}${Math.abs(n)}`;
let _partBy = (xs, f) => xs.reduce(([xss, last], x) => {
let cur = f(x); cur === last || xss.push([]); xss[xss.length-1].push(x); return [xss, cur];
}, [[]])[0];
if (!$toggle.inited) {
$toggle.inited = true;
let _flex = (dir='column') => `display:flex; flex-direction:${dir}`;
let _scroll = (s, bg='lightgrey', thumb='grey', wk='::-webkit-scrollbar') =>
`.${s} {scrollbar-width:thin; scrollbar-color:${thumb} ${bg}} .${s}${wk} {width:6px; height:6px; background:${bg}} .${s}${wk}-thumb {background:${thumb}}`;
GM_addStyle(`.${$} {z-index:10000; position:fixed; top:0; right:0; max-height:${HEIGHT}; max-width:${WIDTH}; background:rgba(0,0,0,.85); color:white}
.${$}:not(.${$}-pin):not(:hover) {opacity:.1} .${$} {${_flex()}} .${$}-header {${_flex('row')}; width:${WIDTH}; flex:0 0; align-items:center}
.${$} input, .${$} textarea {max-width:100%; appearance:auto} .-cyoaoverlay input::-webkit-inner-spin-button {-webkit-appearance:auto}
.${$}-wide {flex: 1} .${$}-noshrink {flex-shrink: 0} .${$} textarea {resize: none} .${$} .${$}-comment {white-space:pre-wrap; text-indent:0}
.${$} p {padding:0 1em; margin:16px 0; text-indent:-1ex; font-size:initial} .${$} ul {margin:1ex 0; list-style-type:initial} .${$} li {margin-left:2em}
.${$} button {color:black; background:lightgrey; padding:0 .5ex; border-radius:5px} .${$}-header input {margin:1ex} .${$} i {display:initial}
.${$}-row {${_flex('row')}; flex:1 0; align-items:center; gap:.5ex; overflow-x:auto; padding:0 1ex} ${_scroll($+'-cols')}
.${$}-rows {overflow-y:auto} ${_scroll($+'-rows')} .${$}-clickable {cursor:pointer} .${$}-clickable:hover {opacity:.8}`);
let _selected = rf.onChanges(o => keys(o).filter(k => o[k].amount > 0).map(name => merge({name, cost: {}}, o[name])),
['_selected'], ['choices']);
rf.regEventDb('init-db', [_selected], () => ({
hidden: false,
pin: false,
view: 'points',
build: "",
builds: [],
syncUrl: false,
focus: null,
extras: false,
filter: false,
edit: true,
group: null,
search: "",
roll: [1, 6, 0],
comment: "",
points: {"points": 0},
choices: {},
}));
let _clearCosts = choices => _mapVals(choices, x => (!_isEmpty(x.cost||{}) ? x : dissoc(x, 'cost')));
let _state = ({comment, points, choices}) => ({comment: comment||void 0, points, choices: _clearCosts(choices)});
let _hash = db => LZString.compressToBase64(JSON.stringify( _state(db) ));
let _store = rf.after(db => {db.build && GM_setValue(db.build, _state(db)); db.syncUrl && (location.hash = _hash(db))});
rf.regCofx('builds', cofx => assoc(cofx, 'builds', GM_listValues()));
rf.regEventDb('set', [_selected], (db, [_, o]) => merge(db, o));
rf.regEventFx('error', ({db}, [_, error]) => ({error}));
rf.regEventFx('set-focus', ({db}, [_, focus=null]) => merge({db: merge(db, {focus})}, focus && {scroll: focus}));
let _pts = (initial, color) => (isArray(initial) ? _pts(initial[0], color) : !COLORS[color] ? initial : [initial, color]);
rf.regEventDb('set-roll', (db, [_, n, m, k, r]) => (![n, m, k].every(_isInt) ? db : merge(db, {roll: !_isInt(r) ? [n, m, k] : [n, m, k, r]})));
rf.regEventFx('roll!', ({db}) => ({roll: db.roll}));
rf.regEventDb('set-comment', [_store], (db, [_, comment]) => merge(db, {comment}));
rf.regEventDb('set-initial', [_store], (db, [_, k, v]) => (!v && (v !== 0) ? db : assocIn(db, ['points', k], _pts(v, db.points[k][1]))));
rf.regEventDb('set-point-color', [_store], (db, [_, k, v]) => assocIn(db, ['points', k], _pts(db.points[k], v)));
rf.regEventDb('set-choice-color', [_selected, _store], (db, [_, k, v]) => (COLORS[v] ? assocIn(db, ['choices', k, 'color'], v) : _dissocIn(db, ['choices', k], 'color')));
rf.regEventDb('set-amount', [_selected, _store], (db, [_, name, v]) => (!v && (v !== 0) ? db : assocIn(db, ['choices', name, 'amount'], v)));
rf.regEventDb('unset-amount', [_selected, _store], (db, [_, name]) => _dissocIn(db, ['choices', name], 'amount'));
rf.regEventDb('set-cost', [_selected, _store], (db, [_, name, k, v]) => (!v && (v !== 0) ? db : assocIn(db, ['choices', name, 'cost', k], v)));
rf.regEventDb('unset-cost', [_selected, _store], (db, [_, name, k]) => _dissocIn(db, ['choices', name, 'cost'], k));
let _renameKey = (o, k0, k1) => o && (k0 in o ? assoc(dissoc(o, k0), k1, o[k0]) : o);
let _renamePoint = (db, k1, k0) => ({points: _renameKey(db.points, k0, k1), choices: _mapVals(db.choices, x => merge(x, x.cost && {cost: _renameKey(x.cost, k0, k1)}))});
let _nameCheck = (db, kind, newName, oldName, upd) =>
(!newName || (newName == oldName) ? {} :
newName in db[kind+'s'] ? {error: `${_cap(kind)} name “${newName}” already exists!`} :
oldName && !(oldName in db[kind+'s']) ? {error: `${_cap(kind)} name “${oldName}” not found!`} :
db.focus && oldName && (db.focus !== oldName) ? {db: merge(db, upd(db, newName, oldName)), scroll: db.focus} :
{db: merge(db, {focus: newName}, upd(db, newName, oldName)), scroll: newName});
rf.regEventFx('rename-point', [_selected, _store], ({db}, [_, k0, k1]) => _nameCheck(db, 'point', _trim(k1), k0, _renamePoint));
rf.regEventFx('rename-choice', [_selected, _store], ({db}, [_, k0, k1]) => _nameCheck(db, 'choice', _trim(k1), k0, () => ({choices: _renameKey(db.choices, k0, _trim(k1))})));
rf.regEventFx('delete-point', ({db}, [_, k]) => ({confirm: {message: `Delete point “${k}”?`, onSuccess: ['_delete-point', k]}}));
rf.regEventDb('_delete-point', [_selected, _store], (db, [_, k]) =>
merge(db, {points: dissoc(db.points, k), choices: _mapVals(db.choices, x => _dissocIn(x, ['cost'], k))}));
rf.regEventFx('delete-choice', ({db}, [_, k]) => ({confirm: {message: `Delete choice “${k}”?`, onSuccess: ['_delete-choice', k]}}));
rf.regEventDb('_delete-choice', [_selected, _store], (db, [_, k]) => merge(db, {choices: dissoc(db.choices, k)}));
entries({point: ['points', 0], choice: ['Choice', {amount: 1}]}).forEach(([kind, [name, value]]) => {
rf.regEventFx(`new-${kind}`, ({db}) =>
({prompt: {message: `Enter new ${name.toLowerCase()} name`, default: `${name} #${keys(db[kind+'s']).length+1}`, onSuccess: [`_new-${kind}`]}}));
rf.regEventFx(`_new-${kind}`, [_selected, _store], ({db}, [_, _name]) =>
_nameCheck(db, kind, _trim(_name), null, (db, name) => ({[kind+'s']: assoc(db[kind+'s'], name, value)})));
});
let _buildCheck = (db, build) => (!build || (build == db.build) ? {} :
GM_getValue(build) ? {error: `Build name “${build}” already exists!`} :
db.build && !GM_getValue(db.build) ? {error: `Build name “${db.build}” not found!`} :
merge({db: merge(db, {build})}, db.build && {delete: db.build}, {dispatch: ['sync-builds']}));
rf.regEventFx('save', ({db}) => ({prompt: {message: "Name your build", default: db.build||document.title, onSuccess: ['_save']}}));
rf.regEventFx('_save', [_selected, _store], ({db}, [_, build]) => _buildCheck(db, _trim(build)));
rf.regEventFx('load', ({db}, [_, build]) => ({confirm: {message: `Load build “${build}”?`, onSuccess: ['_load', build]}}));
rf.regEventFx('_load', ({db}, [_, build=db.build]) => build && {db: merge(db, {build}), load: build});
rf.regEventFx('sync-builds', [rf.injectCofx('builds')], ({db, builds}) => ({db: merge(db, {builds})}));
rf.regEventFx('delete', ({db}, [_, build=db.build]) => ({confirm: {message: `Delete build “${build}” from storage?`, onSuccess: ['_delete', build]}}));
rf.regEventFx('_delete', [_selected, _store], ({db}, [_, build]) => build && {delete: build, dispatch: ['sync-builds']});
rf.regEventFx('export', ({db}, [_, build=db.build]) => build && {export: build});
rf.regEventFx('import', () => ({import: {onSuccess: ['_import'], onFailure: ['error']}}));
let _validPoint = ([k, v]) => k && (k == _trim(k)) && (_isInt(v) || (isArray(v) && (v.length == 2) && _isInt(v[0]) && (v[1] in COLORS)));
let _validChoice = points => ([s, x]) => (s && (s == _trim(s)) && isDict(x) && keys(x).every(k => ['amount', 'color', 'cost'].includes(k))
&& (!('amount' in x) || (_isInt(x.amount) && (x.amount >= 0))) && (!('color' in x) || (x.color in COLORS))
&& (!('cost' in x) || (isDict(x.cost) && entries(x.cost).every(([k, v]) => (k in points) && Number.isFinite(v)))));
let _valid = o => (o && keys(o).every(k => ['comment', 'points', 'choices'].includes(k)) && ['string', 'undefined'].includes(typeof o.comment)
&& isDict(o.points) && entries(o.points).every(_validPoint)
&& isDict(o.choices) && entries(o.choices).every(_validChoice(o.points)));
rf.regEventFx('_import', [_selected, _store], ({db}, [_, data, extra={}]) =>
(!_valid(data) ? {error: "Invalid import data!"} : {db: merge(db, {comment: "", points: {}, choices: {}}, data, extra), dispatch: ['sync-builds']}));
rf.regEventFx('import-hash', ({db}, [_, data]) =>
_valid(data) && {confirm: {message: "Import data from URL hash?", onSuccess: ['_import', data, {syncUrl: true}]}});
rf.regEventFx('sync-url', ({db}, [_, syncUrl=!db.syncUrl]) =>
(!syncUrl || db.syncUrl ? {dispatchLater: {dispatch: ['_sync-url', syncUrl]}} :
{confirm: {message: "Sync URL hash? This overrides existing hash value.", onSuccess: ['_sync-url', syncUrl]}}));
rf.regEventDb('_sync-url', [_store], (db, [_, syncUrl]) => merge(db, {syncUrl}));
rf.regFx('error', alert);
rf.regFx('confirm', ({message, onSuccess}) => confirm(message) && rf.disp(onSuccess));
rf.regFx('prompt', ({message, onSuccess, ...arg}) => setTimeout(() => {
let res = prompt(message, arg.default||"");
(res != null) && rf.disp(onSuccess, res);
}, 250));
rf.regFx('load', (build, data=GM_getValue(build)) => {
if (data) {
let {comment="", points={"points": 0}, choices={}} = data;
rf.disp(['set', {comment, points, choices}]);
rf.dsub(['build-view']) && rf.disp(['sync-builds']);
rf.dsub(['syncUrl']) && rf.disp(['_sync-url', true]);
}
});
rf.regFx('delete', build => {GM_deleteValue(build); (build == rf.dsub(['build']) ? $toggle() : rf.disp(['set', {now: Date.now()}]))});
rf.regFx('scroll', name => setTimeout(() => $toggle.overlay.querySelector(`[name="${name.replace(/"/g, '\\"')}"]`).scrollIntoView({block: 'nearest'}), 100));
let _sortKeys = o => (k, v) => (v === o || !isDict(v) ? v : dict( _sort(keys(v)).map(k => [k, v[k]]) )), _toJson = o => JSON.stringify(o, _sortKeys(o), 2);
rf.regFx('export', build => Object.assign(document.createElement('a'), {
download: `${build}.json`, href: "data:application/json," + encodeURIComponent(_toJson(GM_getValue(build, {})) + '\n'),
}).click());
rf.regFx('import', ({onSuccess, onFailure}, input=document.createElement('input')) => Object.assign(input, {
type: 'file', accept: 'application/json',
onchange () {Promise.resolve(this.files[0]).then(x => x.text()).then(s => rf.disp(onSuccess, JSON.parse(s))).catch(e => rf.disp(onFailure, `${e}`))},
}).click());
rf.regFx('roll', ([n, m, k]) => rf.disp(['set-roll', n, m, k, Array.from({length: n}, _ => Math.floor(1 + m * Math.random())).reduce((a, b) => a+b, k)]));
rf.regSub('hidden', getIn);
rf.regSub('pin', getIn);
rf.regSub('view', getIn);
rf.regSub('build', getIn);
rf.regSub('syncUrl', getIn);
rf.regSub('focus', getIn);
rf.regSub('extras', getIn);
rf.regSub('filter', getIn);
rf.regSub('edit', getIn);
rf.regSub('group', getIn);
rf.regSub('search', getIn);
rf.regSub('roll', getIn);
rf.regSub('comment', getIn);
rf.regSub('points', getIn);
rf.regSub('choices', getIn);
rf.regSub('_selected', getIn);
rf.regSub('builds', getIn);
rf.regSub('builds*', '<-', ['builds'], _sort);
rf.regSub('build-exists?', '<-', ['build'], '<-', ['builds'], ([k, ks]) => k && ks.includes(k));
rf.regSub('point-names', '<-', ['points'], keys);
rf.regSub('point-names*', '<-', ['point-names'], _sort);
rf.regSub('points*', '<-', ['points'], '<-', ['point-names*'], ([o, ks]) => ks.map(k => [k].concat(o[k])));
rf.regSub('choice-names', '<-', ['choices'], keys);
rf.regSub('choice-names-filtered', '<-', ['_selected'], xs => _sort( xs.map(x => x.name) ));
rf.regSub('selected', '<-', ['choice-names-filtered'], '<-', ['choices'], ([ks, o]) => ks.map(name => merge({name, cost: {}}, o[name])));
rf.regSub('choice-names-filtered*', '<-', ['choice-names'], '<-', ['choice-names-filtered'], '<-', ['filter'], ([ks0, ks1, cond]) => cond ? ks1 : ks0);
rf.regSub('choice-names-searched', '<-', ['choice-names-filtered*'], '<-', ['search'], '<-', ['edit'],
([xs, s, edit]) => (!edit || !s ? xs : (s = s.toLowerCase(), xs.filter(x => x.toLowerCase().indexOf(s) >= 0)))); // apparently .includes() can be much slower smh
rf.regSub('choice-names*', '<-', ['choice-names-searched'], _sort);
rf.regSub('choice*', ([_, name]) => rf.subscribe(['choices', name]), (x, [_, name]) => merge({name}, x));
let _group = s => (s.match(/^([^:]+): /) || [s, ""])[1];
rf.regSub('choice-groups', '<-', ['choice-names*'], ks => _partBy(ks, _group).map(ss => [_group(ss[0]), ss]));
rf.regSub('choice-groups*', '<-', ['choice-groups'], grs => grs.reduce((o, [k, xs]) => ((k ? o[k] = xs : [].push.apply(o[k], xs)), o), {"": []}));
rf.regSub('choice-group', '<-', ['choices'], '<-', ['choice-groups*'], ([o, groups], [_, s]) => dict( (groups[s]||[]).map(k => [k, o[k]])));
rf.regSub('choice-group-names', '<-', ['choice-groups*'], keys);
rf.regSub('#choices', '<-', ['choice-names'], xs => xs.length);
rf.regSub('#selected', '<-', ['_selected'], xs => xs.length);
rf.regSub('build-view', '<-', ['view'], s => s === 'build');
rf.regSub('roll-tooltip', '<-', ['roll'], ([n, m, k]) => `Roll ${m}-sided die` + (n == 1 ? "" : ` ${n} times`) + (!k ? "" : ` and add ${_delta(k, '')} to the result`));
rf.regSub('choice-tooltip', ([_, name]) => [['choices', name], ['point-names*']].map(rf.subscribe), ([{amount=0, cost={}}={}, pts], [_, name]) => [
` ${name} [×${amount}]`,
...pts.map(k => [k, cost[k]]).map(([k, v]) => v && (_delta(v) + (amount < 2 ? '' : ` × ${amount} = ${_delta(v*amount)}`) + ` ${k}`)).filter(s => s),
].join('\n'));
rf.regSub('total', '<-', ['_selected'], (xs, [_, k]) => xs.map(x => x.amount * (x.cost[k]||0)).reduce((a, b) => a+b, 0));
$toggle.onpress = evt => {(evt.key == 'Escape') && rf.disp(['set', (rf.dsub(['focus']) ? {focus: null} : rf.dsub(['pin']) ? {} : {hidden: true})])};
$toggle.loadBuild = () => rf.dsub(['build']) && rf.disp(['_load']);
$toggle.syncBuilds = () => rf.disp(['sync-builds']);
}
if ($toggle.overlay) {
r.render(null, $toggle.overlay), rf.dispatchSync(['init-db']), rf.purgeEventQueue(), rf.clearSubscriptionCache();
$toggle.overlay.remove();
clearInterval($toggle.syncBuilds);
window.removeEventListener('focus', $toggle.loadBuild);
window.removeEventListener('focus', $toggle.syncBuilds);
document.removeEventListener('keydown', $toggle.onpress);
$toggle.overlay = null;
} else {
window.addEventListener('focus', $toggle.loadBuild);
window.addEventListener('focus', $toggle.syncBuilds);
document.addEventListener('keydown', $toggle.onpress);
document.body.append($toggle.overlay = document.createElement('div'));
let _len = s => `${s}`.length+1, _lenEm = s => `${2 + _len(s)*2/3}em`;
let _num = s => s && Number(s);
let _unprefix = (s, p) => (!p || !s.startsWith(p+": ") ? s : s.slice(p.length+2));
let _style = (color, background="rgba(0,0,0,.5)") => ({background, color: COLORS[color]||'white'});
let $setHidden = (x=true) => () => {rf.disp(['set', {hidden: x}])};
let $setView = x => () => {rf.disp(['set', {view: x, focus: null}]); (x === 'build') && $toggle.syncBuilds()};
let setFocus = (focus=null) => {rf.dispatchSync(['set-focus', focus])};
let setAmount = (name, value) => rf.dispatchSync([(value == '0' ? 'unset-amount' : 'set-amount'), name, _num(value)]);
let $setCost = (name, key) => function () {rf.dispatchSync([(this.value == '0' ? 'unset-cost' : 'set-cost'), name, key, _num(this.value)])};
let setGroup = name => rf.dispatchSync(['set', {group: name||null}]);
let toggleExtras = () => {rf.disp(['set', {extras: !rf.dsub(['extras'])}])};
let setRoll = (n, m, k) => {rf.dispatchSync(['set-roll', ...[n, m, k].map(_num)])}
let overrideKeyboard = ({dom}) => {dom.onkeyup = dom.onkeydown = dom.onkeypress = function (e) {$toggle.onpress(e); e.stopPropagation()}};
let [TextInput, NumberInput, Checkbox] = ["", "[type=number]", "[type=checkbox]"].map(s =>
(color, attrs, redraw=false) => [`input${s}`, merge(attrs, {oncreate: overrideKeyboard}, color && {style: merge(_style(color), attrs.style)})]);
let TextArea = (attrs, redraw=false) => ['textarea', merge(attrs, {oncreate: overrideKeyboard}), attrs.value];
let Header = () => [`.${$}-header`, [Checkbox, '', {title: "Pin", checked: rf.dsub(['pin']), onchange () {rf.disp(['set', {pin: this.checked}])}}], ...({
points: [['button', {onclick: $setView('choices')}, "Choices"],
[`.${$}-row`, ['b', "Points"], ['button', {title: "Build", onclick: $setView('build')}, rf.dsub(['build']) || ['i', "<unnamed>"]]]],
choices: [['button', {onclick: $setView('points')}, "Points"],
rf.dsub(['edit']) && ['button', {title: "Extra controls", onclick: toggleExtras}, (rf.dsub(['extras']) ? "˄" : "˅")],
[`.${$}-row`, ...rf.dsub(['points*']).map(([key, initial, color]) =>
['b', {key, title: key, style: _style(color, '')}, _delta(initial+rf.dsub(['total', key]), '')])]],
build: [['button', {onclick: $setView('points')}, "Points"],
[`.${$}-row`, ['b', `Build [${rf.dsub(['#selected'])}/${rf.dsub(['#choices'])} choices]`]],
['button', {title: `${rf.dsub(['syncUrl']) ? "Unsync" : "Sync"} URL hash with state`, onclick: () => rf.disp(['sync-url'])},
(rf.dsub(['syncUrl']) ? "Unhash" : "Hash")]],
}[ rf.dsub(['view']) ] || []), ['button', {title: "Collapse", onclick: $setHidden()}, "˃"]];
let ColorSelector = (kind, name, value='white') =>
['select', {value, style: _style(value, 'black'), onchange () {rf.dispatchSync([`set-${kind}-color`, name, this.value])}},
...keys(COLORS).map(s => ['option', {style: _style(s, 'black')}, s])]; // in Firefox: enable dom.forms.select.customstyling (in about:config)
let PointDetails = (name, value, color='white') => [`.${$}-row`, {name}, ['button', {onclick: () => setFocus()}, '×'],
[TextInput, color, {title: "Name", value: name, size: _len(name), onchange () {setTimeout(() => rf.dispatch(['rename-point', name, _trim(this.value)]), 10)}}],
[NumberInput, color, {title: "Initial", value, style: {width: _lenEm(value)}, onchange () {rf.dispatch(['set-initial', name, _num(this.value)])}}, true],
[ColorSelector, 'point', name, color],
['button', {onclick: () => rf.disp(['delete-point', name])}, "delete"]];
let ChoiceView = (name, amount, color='white', elt=`.${$}-row`, prefix="") =>
[elt, {name, title: rf.dsub(['choice-tooltip', name]), style: {..._style(color, ''), opacity: (amount > 0 ? 1 : .5)}},
(amount > 0 ? ['b', _unprefix(name, prefix)] : ['del', ['i', _unprefix(name, prefix)]]), (amount > 1) && ` [×${amount}]`];
let ChoiceDetails = (name, amount, color='white', cost={}) => [`.${$}-rows`, {name},
[`.${$}-row`, ['button', {onclick: () => rf.disp(['delete-choice', name])}, "delete"],
[TextInput, color, {class: `${$}-wide`, value: name, onchange () {setTimeout(() => rf.dispatch(['rename-choice', name, this.value]), 10)}}],
[ColorSelector, 'choice', name, color],
['button', {onclick: () => setFocus()}, '×']],
...rf.dsub(['points*']).map(([key, _, color='white']) =>
[`.${$}-row`, {style: _style(color, '')}, key,
[NumberInput, color, {value: cost[key]||0, style: {width: _lenEm(cost[key]||0)}, onchange: $setCost(name, key)}, true]])];
let ChoicesList = () => ['<>', ...rf.dsub(['choice-names*']).map(k => rf.dsub(['choice*', k])).map(({name, amount, color='white', cost}) => r.with({key: name},
(!rf.dsub(['edit']) ? [ChoiceView, name, amount, color] :
rf.dsub(['focus']) == name ? [ChoiceDetails, name, amount, color, cost||{}] :
[`.${$}-row`, (amount > 1 ? [NumberInput, color, {name, min: 0, value: amount, style: {width: _lenEm(amount)}, onchange () {setAmount(name, this.value)}}, true] :
['<>', [Checkbox, color, {checked: amount > 0, onchange () {setAmount(name, (this.checked ? 1 : 0))}}],
(amount > 0) && ['button', {onclick: () => setAmount(name, 2)}, "˄"]]),
[`.${$}-row.${$}-clickable`, {name, title: rf.dsub(['choice-tooltip', name]), style: _style(color, ''),
onclick: () => setAmount(name, (amount > 0 ? 0 : 1))}, name],
['button', {onclick: () => setFocus(name)}, "edit"]])))];
let _ChoiceView = (prefix="", elt, key=true) => ({name, amount, color}) => r.with(key && {key: name}, [ChoiceView, name, amount, color, elt, prefix]);
let _choiceGroup = ks => ks.map(k => rf.dsub(['choice*', k]));
let ChoiceGroups = ({lists}) => ['<>', ...rf.dsub(['choice-groups']).map(([group, ks], i) => r.with({key: group||i},
(!group ? [`.${$}-rows`, ..._choiceGroup(ks).map(_ChoiceView())] :
!lists ? ['p', group, ": ", ['<>', ..._choiceGroup(ks).map((x, i) => r.with({key: x.name}, ['<>', (i>0) && ", ", _ChoiceView(group, 'span', false)(x)]))], "."] :
[`.${$}-rows`, ['p', group, ":"], ['ul', ..._choiceGroup(ks).map(_ChoiceView(group, 'li'))]])))];
let Body = () => ['<>', ...({
points: [[`.${$}-rows`, ...rf.dsub(['points*']).map(([name, value, color]) => r.with({key: name},
(rf.dsub(['focus']) == name ? ['<>', [PointDetails, name, value, color]] :
[`.${$}-row.${$}-clickable`, {name, style: _style(color, ''), onclick: () => setFocus(name)},
name, ": ", _delta(value+rf.dsub(['total', name]), ''), "/", _delta(value, '')])))],
[`.${$}-row`, [`button.${$}-wide`, {title: "Add", onclick: () => rf.disp(['new-point'])}, "+"]]],
choices: [[`.${$}-rows.${$}-noshrink`, {style: (!rf.dsub(['extras']) || !rf.dsub(['edit'])) && {display: 'none'}},
(([n, m, k, r] = rf.dsub(['roll'])) =>
[`.${$}-row`, [NumberInput, '', {value: n, style: {width: _lenEm(n)}, min: 1, onchange () {setRoll(this.value, m, k)}}, true], 'd',
[NumberInput, '', {value: m, style: {width: _lenEm(m)}, min: 2, onchange () {setRoll(n, this.value, k)}}, true], '+',
[NumberInput, '', {value: k, style: {width: _lenEm(k)}, onchange () {setRoll(n, m, this.value)}}, true],
['button', {title: rf.dsub(['roll-tooltip']), onclick: () => rf.disp(['roll!'])}, "roll ", ['b', `${n}d${m}${!k ? '' : _delta(k)}`]],
(r != null) && ['<>', ['b', ` = ${_delta(r, '')}`], ['button', {onclick: () => setRoll(n, m, k)}, '×']]])(),
[`.${$}-row`, [TextArea, {class: `${$}-wide`, title: "Comment", placeholder: "Comment", rows: 6,
value: rf.dsub(['comment']), onchange () {rf.dispatch(['set-comment', this.value])}}]]],
rf.dsub(['edit']) &&
[TextInput, 'white', {class: `${$}-wide`, title: "Search", placeholder: "Search", value: rf.dsub(['search']), oninput () {rf.disp(['set', {search: this.value}])}}],
[`.${$}-rows`, ((s = !rf.dsub(['edit']) && rf.dsub(['group'])) => (!s ? [ChoicesList] : [ChoiceGroups, {lists: s == 'groups'}]))(),
!rf.dsub(['edit']) && [`p.${$}-comment`, ['em', rf.dsub(['comment'])]]],
[`.${$}-row`, [Checkbox, '', {title: "Show selected", checked: rf.dsub(['filter']), onchange () {rf.disp(['set', {filter: this.checked}])}}],
(rf.dsub(['edit']) ? [`button.${$}-wide`, {title: "Add", onclick: () => rf.disp(['new-choice'])}, "+"] :
[`select.${$}-wide`, {title: "Grouping", style: _style(), value: rf.dsub(['group'])||"", onchange () {setGroup(this.value)}},
...entries(GROUPING).map(([k, s]) => ['option', {value: s}, k])]),
[Checkbox, '', {title: "Read only", checked: !rf.dsub(['edit']), onchange () {rf.disp(['set', {edit: !this.checked}])}}]]],
build: [((k=rf.dsub(['build']), x=rf.dsub(['build-exists?'])) => [`.${$}-row`, {style: {background: 'grey'}},
(!x ? ['<>', ['button', {onclick: () => rf.dispatchSync(['import'])}, "Import"],
[`button.${$}-row`, {onclick: () => rf.disp(['save'])}, "Save (in storage)"]]
: ['<>', ['button', {onclick: () => rf.dispatchSync(['export', k])}, "Export"],
[`.${$}-row.${$}-clickable`, {title: "Rename", onclick: () => rf.disp(['save'])}, k],
['button', {onclick: () => rf.disp(['delete', k])}, "Delete"]])])(),
[`.${$}-rows`, ...rf.dsub(['builds*']).map(k => (k != rf.dsub(['build'])) && [`.${$}-row`,
['button', {onclick: () => rf.dispatchSync(['export', k])}, "Export"],
(rf.dsub(['build-exists?']) ? [`.${$}-row`, {title: ""}, k] : [`.${$}-row.${$}-clickable`, {title: "Load", onclick: () => rf.disp(['load', k])}, k]),
['button', {onclick: () => rf.disp(['delete', k])}, "Delete"]])]],
}[ rf.dsub(['view']) ] || [])];
let Overlay = () =>
[`.${$}`, {class: [rf.dsub(['pin']) && `${$}-pin`]},
(!rf.dsub(['hidden']) ? ['<>', [Header], [Body]] : ['button', {title: "Expand", onclick: $setHidden(false)}, "˂"])];
rf.dispatchSync(['init-db']);
try {rf.disp(['import-hash', JSON.parse(LZString.decompressFromBase64( location.hash.slice(1) ))])} catch (e) {}
r.render([Overlay], $toggle.overlay);
}
});
if (HOST == GITLAB) {
let VREGEX = RegExp("(?<=^/cyoa-viewer/v).(?=\.html$)");
let v = location.pathname.match(VREGEX)?.[0], _v = (v == '1' ? '2' : '1');
GM_registerMenuCommand("Switch to v"+_v, () => {location.pathname = location.pathname.replace(VREGEX, _v)});
(location.search.length > 1) && GM_registerMenuCommand("Open in URL Builder", () => {
let title = encodeURIComponent(document.title.replace(/( \| )?CYOA Viewer$/, ""));
GM_openInTab(`https://${GITLAB}/cyoa-viewer/${location.search}&title=${title}`, {active: true, insert: true, setParent: true});
});
}
if (HOST == IMGUR)
(([_, id] = URI.match("^/(?:a|gallery)/([0-9A-Za-z]+)")||[]) => id && GM_registerMenuCommand("Open in Cubari", () => GM_openInTab(`https://${CUBARI}/read/imgur/${id}/1`)))();
else if (HOST == IMAGECHEST)
(([_, id] = URI.match("^/p/([0-9a-z]+)")||[]) => id && GM_registerMenuCommand("Open in Cubari", () => GM_openInTab(`https://${CUBARI}/read/imgchest/${id}/1`)))();
else if (HOST == CUBARI)
(([_, site, id] = URI.match("^/read/(imgur|imgchest)/([0-9A-Za-z]+)")||[]) => id &&
GM_registerMenuCommand("Open in "+(site == 'imgur' ? "Imgur" : "ImageChest"), () => GM_openInTab(`https://${site}.com/${site == 'imgur' ? 'a' : 'p'}/${id}`)))();
if ((HOST == FUNNYJUNK) && (document.querySelectorAll(".cImg").length != 1)) return; // not a gallery
let slider, ticks, html = document.documentElement, pages = [], showPage = e => e?.scrollIntoView(), setPages = xs => {
pages = Array.from(xs);
(HOST == IMGUR) && _imgurShowPage();
(HOST == IMAGECHEST) && pages.forEach((e, i) => Object.assign(e, {idx: i+1, name: e.alt.replace("Image For Post | ", `Page ${i+1}\n`).trim()}));
(HOST == FUNNYJUNK) && pages.forEach((e, i) => {Object.assign(e, {idx: i+1, url: e.parentNode.parentNode.href, _container: e.parentNode.offsetParent});
e.parentNode.style=""});
slider.max = pages.length;
ticks.innerHTML = "";
pages.forEach((x, i) => ticks.append(Object.assign(document.createElement('option'), {value: i+1, label: i+1, title: x.name||`Page ${i+1}`, onclick: () => slider.selectPage(i+1)})));
slider.style.height = ticks.style.height = `calc(${slider.max} * 18px)`;
slider._top = 0;
setTimeout(() => ticks.setMore(document.querySelector("button:is(.loadMore, .load-all)")));
return xs;
}, _imgurShowPage = () => {
try {
document.querySelector(".btn-wall--yes")?.click();
let pad = document.querySelector(".Gallery-Content--mediaContainer").parentNode.firstChild, height = o => o.height * Math.min(pad.offsetWidth, o.width) / o.width;
showPage = (page, e = document.querySelector(`img[src="${page.url}"]`)) =>
(e ? e.scrollIntoView() : html.scroll({top: pages.slice(0, pages.indexOf(page)).reduce((n, o) => n + height(o) + 24, pad.offsetTop)}));
setTimeout(() => ticks.setMore(document.querySelector("button:is(.loadMore, .load-all)")));
} catch (e) {setTimeout(_imgurShowPage, 100)}
};
if ([IMGUR, IMAGECHEST, FUNNYJUNK].includes(HOST)) { // CYOA mode toggler
const INITIAL = {[IMGUR]: 15}[HOST];
let $ = '-cyoamode', $$ = '-cyoapager';
console.warn($);
GM_addStyle(`button.${$} {position:fixed; top:5em; left:1ex; z-index:1000; writing-mode:sideways-lr; padding:1px} button.${$} span {writing-mode:vertical-lr; transform:rotate(180deg)}`);
GM_addStyle({
[IMGUR]: `.${$} .NewCover, .${$} .Gallery-Sidebar, .${$} .Gallery-EngagementBar, .${$} .BottomRecirc, .${$} .Footer, .${$} .Navigation-next,
.${$} .Gallery-Content--ad {display: none !important} .${$} .Gallery {max-width: 100% !important; width: initial !important}`,
[IMAGECHEST]: `.${$} .side-div {display: none} .${$} .container {max-width: calc(100vw - 8ex)} .${$} .post-div {max-width: 100%; flex: 1}`,
[FUNNYJUNK]: `#contentRight:not(.collapsed) button.${$} {left: calc(310px + 1em)} button.${$} {top: calc(78px + 6em); left: 1em}
.${$} header, .${$} .channels_bar, .${$} #columnLeftSeparator, .${$} #comms, .${$} #bottt,
.${$} #vvl, .${$} #vvr, .${$} #bottomNav, .${$} #tapContent, .${$} #blockD {display: none}
.${$} .mediaContainer, .${$} .contentContainer {max-width: none !important} .${$} img {width: auto !important; max-width: 100%}`,
}[HOST]);
let _toggle = {
[FUNNYJUNK]: on => {/* blocking shortcuts */ setTimeout(() => {document.locationAlreadyChanged = on}, 1000);
/* toggling left panel */ (!on == mz.classList.contains('collapsed')) && doLeftCol();
/* loading large images */ on && setTimeout(() => pages.forEach(e => Object.assign(e, {src: e.url, loaded: true})))},
[IMGUR]: on => (!on ? _toggle.observer?.disconnect() : setTimeout((gallery = document.querySelector(`.Gallery-ContentWrapper`)) => {
let fixSrc = e => e.querySelectorAll(`img:not(.image-placeholder)`).forEach(e => {e.src = e.src?.replace(/_d(\.[a-z]{3,4}(\?.*)?)$/i, "$1")});
(_toggle.observer = new MutationObserver(xs => xs.forEach(x => x.addedNodes.forEach(fixSrc)))).observe(gallery, {childList: true, subtree: true});
fixSrc(gallery); // fixing image URLs; smh this in necessary in Chrome on some galleries?
}, 1000)),
}[HOST] || (() => {});
let toggle = (on = !localStorage.getItem($)) => {_toggle(on);
document.body.classList[on ? 'add' : 'remove']($);
window.dispatchEvent(new Event('resize'));
localStorage[on ? 'setItem' : 'removeItem']($, "on");
slider._top = 0};
console.warn($$);
GM_addStyle(`.${$} .${$$} {position: fixed; top: 5em; right: 3.25ex; transform: translate(50%); text-align: center; z-index: 1000; display: block}
.${$$} {display: none; max-height: calc(100% - 6em); overflow-y: auto; overflow-x: hidden; scrollbar-width:none} .${$$}::-webkit-scrollbar {display: none}
.${$$} .selector {display: flex} .${$$} button {margin-top: 1ex; padding: 0 .25ex} .${$$}:not(.more) button {display: none}
.${$$} input {writing-mode: bt-lr; appearance: slider-vertical; rotate: 180deg; width: 1.5em; margin-top: 0} .${$$} input:out-of-range {display: none}
.${$$} datalist {display: flex; flex-direction: column; justify-content: space-between; width: 1.5em}
.${$$} option {padding: 0; font-weight: bold} .${$$} option:not(.active) {opacity: 0.5} .${$$} option:hover {opacity: 0.8; cursor: pointer}`);
(HOST == FUNNYJUNK) && GM_addStyle(`.${$} .${$$} {font-size: larger; top: 10em; right: 6.5ex; max-height: calc(100% - 11em)}`);
unsafeWindow.chrome && GM_addStyle({[IMGUR]: `.${$} .${$$} {font-size: 15px}`, [IMAGECHEST]: `.${$} .${$$} {right: 2.5ex} .${$$} input {width: 1em}`}[HOST]);
let [more, selector, pager] = ['button', 'div', 'div'].map(s => document.createElement(s));
let _redraw = () => Array.from(ticks.children).forEach(e => e.classList[e.value == slider.value ? 'add' : 'remove']('active'));
let visible = img => ((pos = html.scrollTop, top = (img._container||img.offsetParent).offsetTop) => (top < pos + html.clientHeight) && (top + img.offsetHeight > pos))();
slider = Object.assign(document.createElement('input'), {type: 'range', min: 1, max: 0, _top: 0}, {
oninput: () => {_redraw(); showPage(pages[slider.value-1])},
selectPage: n => {slider.value = n; slider.oninput()},
title: (HOST != IMGUR ? "" : "On Imgur: before using, SCROLL THROUGH ALL PAGES (i.e. drag the slider from first to last tick one-by-one, or just hold down PgDown)\n" +
"For any page that Imgur HASN'T LOADED YET, Imgur WILL mess up positioning of ALL THE SUBSEQUENT PAGES"),
});
setInterval(() => {
if ((pages.length < 2) || (Math.abs(html.scrollTop - slider._top) < 10)) return;
let images = Array.from(document.querySelectorAll("#post-images img, .Gallery-Content img:not(.image-placeholder), .contentContainer img")).filter(visible);
let img = images[slider._top > html.scrollTop ? 0 : images.length-1], value = img?.idx || pages.findIndex(o => img?.src.startsWith(o.url?.replace(/\.[a-z]{3,4}$/i, "")))+1;
value && (Object.assign(slider, {value, _top: html.scrollTop}), _redraw());
}, 500);
slider.setAttribute('orient', 'vertical'); // Firefox
ticks = Object.assign(document.createElement('datalist'), {}, {
setMore: btn => {
pager.classList[btn ? 'add' : 'remove']('more');
btn && btn.addEventListener('click', () => {
more.innerText = "…";
setTimeout(() => setPages(HOST == IMGUR ? pages : document.querySelectorAll("#post-images img")), 2500);
});
btn && Object.assign(more, {onclick: () => btn.click(), title: btn.innerText, innerText: "+" + (btn.innerText.match("^Load ([0-9]+)")?.[1] || "")});
INITIAL && (slider.max = (!btn ? pages.length : Math.min(slider.max, INITIAL)), ticks.children.forEach((e, i) => (i >= INITIAL) && (e.style.display = (!btn ? '' : 'none'))));
INITIAL && (slider.style.height = ticks.style.height = `calc(${slider.max} * 18px)`);
},
});
slider.style.height = ticks.style.height = 0;
[ticks, slider].forEach(e => selector.append(e)); Object.assign(selector, {className: "selector"});
[selector, more].forEach(e => pager.append(e)); Object.assign(pager, {className: $$});
let container = (HOST == FUNNYJUNK ? contentRight : document.body);
container.append(Object.assign(document.createElement('button'), {innerHTML: (navigator.vendor ? "<span>CYOA</span>" : "CYOA"), className: $, onclick () {toggle()}}));
container.append(pager);
addEventListener('keydown', e => ['ArrowLeft', 'ArrowRight'].includes(e.key) && localStorage.getItem($) && e.stopImmediatePropagation());
toggle(localStorage.getItem($));
}
if ([IMGUR, IMAGECHEST, CUBARI, IMGBB, FUNNYJUNK].includes(HOST) || HOST.match(REDDIT) || ['jpg', 'jpeg', 'png', 'webp'].some(s => URI.endsWith(`.${s}`))) { // "Open in Viewer"
let {dict, keys} = require('mreframe/util'), array = Array.from, find = sel => document.querySelector(sel), findAll = sel => array(document.querySelectorAll(sel));
let titlePrompt = () => prompt("CYOA name:", document.title)||"", title = () => document.title || titlePrompt();
let commonPrefix = (s="", z="", ...ss) => {
if (ss.length > 0)
return commonPrefix(commonPrefix(s, z), ...ss);
s = `${s}`, z = `${z}`;
let l = 0, r = 1 + Math.min(s.length, z.length);
while (l+1 < r) {let m = Math.floor((l+r) / 2);
if (s.startsWith( z.slice(0, m) )) l = m; else r = m}
return s.slice(0, l);
}
let urlsToViewerUri = (urls, hash) => {let p = commonPrefix(...urls), o = new URLSearchParams();
p && o.set('prefix', p);
urls.map(s => s.slice(p.length)).forEach(s => o.append('url', s));
return `${Object.assign(new URL("https://"+GITLAB+"/cyoa-viewer/v1.html?"+o), {hash})}`};
const LOAD_FAILED = "Failed to collect URLs, try reloading the page.";
let urls = null, _load = () => {}, load = () => Promise.resolve(urls || _load()).then(xs => (urls = xs)?.length > 0 ? Promise.resolve(urls) : Promise.reject(LOAD_FAILED));
if (HOST == IMGUR) {
try {urls = setPages(JSON.parse(postDataJSON).media).map(x => x.url)} catch (e) {}
let _fetch = unsafeWindow.fetch;
let spy = (x, getUrls) => {let _json = x.json;
return Object.assign(x, {json: () => _json.call(x).then(o => {try {urls = urls || getUrls(o)} catch (e) {console.error(e)}; return o})})}
title = () => (find(".Gallery-Title h1")||{}).innerText;
unsafeWindow.fetch = (url, ...args) => _fetch(url, ...args).then(x => (!url.match("^https://api.imgur.com/post/v1/(albums|media)/") ? x : spy(x, o => setPages(o.media).map(y => y.url))));
} else if (HOST == IMAGECHEST) {
title = () => (find(".card-header h4")||{}).innerText;
setPages(findAll("#post-images img"));
_load = () => new Promise(resolve => {
let btn = find('button.load-all');
(!btn ? resolve(setPages(findAll("#post-images img")).map(e => e.src)) :
(btn.click(), new MutationObserver(xs => xs.forEach(x => x.removedNodes.forEach(e => (e == btn) && resolve(_load())))).observe(btn.parentNode, {childList: true})));
});
} else if (HOST == CUBARI) {
title = () => document.title.replace(/ \| Chapter [0-9]+ \| Cubari$/, "");
_load = () => fetch(`https://${HOST}${BASE_API_PATH}${URI.match(`^/read/.*?/(.*?)/.*`)[1]}`).then(x => x.json())
.then(o => o.chapters[1].groups[1].map(x => Object.assign(x.src||x, {name: x.description})));
} else if (`${location}`.match(`^https://${IMGBB}/album/`)) {
title = () => (find("a[data-text=album-name]")||{}).innerText;
_load = () => {let o = dict(findAll("#content-listing-tabs .list-item").map(e => JSON.parse(decodeURIComponent(e.getAttribute('data-object')))).map(o => [o.name, o.url]));
return keys(o).sort().map(k => o[k])};
} else if (HOST == IMGBB) {
title = () => (find("h1.viewer-title")||{}).innerText;
_load = () => findAll("#image-viewer img").map(e => e.src);
} else if (HOST == FUNNYJUNK) {
setPages(findAll(".contentContainer img"));
_load = () => pages.map(e => e.url);
} else if (HOST.match(REDDIT)) {
title = () => (find("[data-test-id=post-content] h1, [view-context=CommentsPage] [slot=title], .top-matter a.title") ||
find('post-bottom-bar')?.shadowRoot?.querySelector("[data-testid=post-title]") || {}).innerText;
_load = () => findAll("[data-test-id=post-content] figure a, [view-context=CommentsPage] img[role=presentation], .gallery-tile img.preview, " +
"[data-test-id=post-content] .ImageBox-image, .media-preview > .media-preview-content img.preview, zoomable-img > img")
.map(e => `https://i.redd.it${new URL(e.src||e.href).pathname.replace(/(?<=^\/).*-/, "")}`);
} else [urls, title] = [[`${location}`], () => ""]
GM_registerMenuCommand("Open in Viewer", () => load().then(urls => GM_openInTab(urlsToViewerUri(urls, title()||titlePrompt()), {active: true, insert: true, setParent: true}))
.catch(e => alert(e)));
}
})();