// ==UserScript==
// @name Quick CYOA calculator
// @namespace http://tampermonkey.net/
// @version 0.4
// @description Overlay for quick CYOA playing
// @author agreg
// @match https://cubari.moe/*
// @match https://imgur.com/*
// @match https://imgchest.com/*
// @match https://ibb.co/*
// @match https://agregen.gitlab.io/cyoa-viewer/v1.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/mreframe/dist/mreframe.js
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @grant unsafeWindow
// ==/UserScript==
(function() {
'use strict';
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 _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 {max-width:100%; appearance:auto} .-cyoaoverlay input::-webkit-inner-spin-button {-webkit-appearance:auto} .${$}-wide {flex: 1}
.${$} 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}`);
rf.regEventDb('init-db', () => ({
hidden: false,
pin: false,
view: 'points',
build: "",
sync: false,
focus: null,
filter: false,
edit: true,
group: null,
points: {"points": 0},
choices: {},
}));
let _clearCosts = choices => _mapVals(choices, x => (!_isEmpty(x.cost||{}) ? x : dissoc(x, 'cost')));
let _store = rf.after(({build, points, choices}) => build && GM_setValue(build, {points, choices: _clearCosts(choices)}));
rf.regEventDb('set', (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}));
rf.regEventFx('set-sync', ({db}, [_, fast]) => ({db: merge(db, {sync: fast}), sync: fast}));
let _pts = (initial, color) => (isArray(initial) ? _pts(initial[0], color) : !COLORS[color] ? initial : [initial, color]);
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', [_store], (db, [_, k, v]) => (COLORS[v] ? assocIn(db, ['choices', k, 'color'], v) : _dissocIn(db, ['choices', k], 'color')));
rf.regEventDb('set-amount', [_store], (db, [_, name, v]) => (!v && (v !== 0) ? db : assocIn(db, ['choices', name, 'amount'], v)));
rf.regEventDb('set-cost', [_store], (db, [_, name, k, v]) => (!v && (v !== 0) ? db : assocIn(db, ['choices', name, 'cost', k], v)));
rf.regEventDb('unset-cost', [_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, {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', [_store], ({db}, [_, k0, k1]) => _nameCheck(db, 'point', _trim(k1), k0, _renamePoint));
rf.regEventFx('rename-choice', [_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', [_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', [_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}`, [_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}));
rf.regEventFx('save', ({db}) => ({prompt: {message: "Name your build", default: db.build||document.title, onSuccess: ['_save']}}));
rf.regEventFx('_save', [_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('delete', ({db}, [_, build=db.build]) => ({confirm: {message: `Delete build “${build}” from storage?`, onSuccess: ['_delete', build]}}));
rf.regEventFx('_delete', [_store], ({db}, [_, build]) => build && {delete: build});
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))
&& _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) && _isInt(v)))));
let _valid = o => (keys(o).every(k => ['points', 'choices'].includes(k))
&& isDict(o.points) && entries(o.points).every(_validPoint)
&& isDict(o.choices) && entries(o.choices).every(_validChoice(o.points)));
rf.regEventFx('_import', [_store], ({db}, [_, data]) => (!_valid(data) ? {error: "Invalid import data!"} : {db: merge(db, {points: {}, choices: {}}, data)}));
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 {points={"points": 0}, choices={}} = data;
rf.disp(['set', {points, choices}]);
}
});
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'}), 10));
let _sortKeys = o => (k, v) => (v === o || !isDict(v) ? v : dict( keys(v).sort().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: encodeURI(`data:application/json,${_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('sync', fast => {clearInterval($toggle.sync); $toggle.sync = setInterval(() => rf.disp(['_load']), 5*1000*(fast ? 1 : 60))});
rf.regSub('hidden', getIn);
rf.regSub('pin', getIn);
rf.regSub('view', getIn);
rf.regSub('build', getIn);
rf.regSub('sync', getIn);
rf.regSub('focus', getIn);
rf.regSub('filter', getIn);
rf.regSub('edit', getIn);
rf.regSub('group', getIn);
rf.regSub('points', getIn);
rf.regSub('choices', getIn);
rf.regSub('points*', '<-', ['points'], o => keys(o).sort().map(k => [k].concat(o[k])));
let _optFilter = (xs, when, pred) => (!when ? xs : xs.filter(pred));
rf.regSub('choices*', '<-', ['choices'], '<-', ['filter'], ([o, cond]) => _optFilter(keys(o).sort().map(name => merge({name}, o[name])),
cond, x => x.amount > 0));
let _group = o => (o.name.match(/^([^:]+): /) || [o.name, ""])[1];
rf.regSub('choice-groups', '<-', ['choices*'], xs => _partBy(xs, _group).map(xs => [_group(xs[0]), xs]));
rf.regSub('#choices', '<-', ['choices'], o => keys(o).length);
rf.regSub('#selected', '<-', ['choices*'], xs => xs.filter(x => x.amount > 0).length);
rf.regSub('choice-tooltip', '<-', ['choices'], '<-', ['points*'], ([o, pts], [_, name], {amount=0, cost={}}=o[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'));
let _addAll$ = (o1, o2, num=1) => (keys(o2).forEach(k => {o1[k] = (o1[k]||0) + num*(o2[k]||0)}), o1);
rf.regSub('costs', '<-', ['choices*'], xs => xs.reduce((o, x) => (!x.amount ? o : _addAll$(o, x.cost, x.amount)), {}));
rf.regSub('totals', '<-', ['points*'], '<-', ['costs'], ([points, costs]) =>
points.map(([name, initial, color]) => [name, initial, color, costs[name]||0]));
$toggle.onpress = evt => {(evt.key == 'Escape') && rf.disp(['set', (rf.dsub(['focus']) ? {focus: null} : rf.dsub(['pin']) ? {} : {hidden: true})])};
}
if ($toggle.overlay) {
rf.dispatchSync(['init-db']);
$toggle.overlay.remove();
clearInterval($toggle.sync);
document.removeEventListener('keydown', $toggle.onpress);
$toggle.sync = $toggle.overlay = null;
} else {
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}])};
let setFocus = (focus=null) => {rf.dispatchSync(['set-focus', focus])};
let setAmount = (name, value) => rf.dispatchSync(['set-amount', name, _num(value)]);
let $setCost = (name, key) => e => rf.dispatchSync([(e.value == '0' ? 'unset-cost' : 'set-cost'), name, key, _num(e.value)]);
let setGroup = name => rf.dispatchSync(['set', {group: name||null}]);
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, onchange (e) {e.redraw = redraw; attrs.onchange(this)}},
color && {style: merge(_style(color), attrs.style)})]);
let Header = () => [`.${$}-header`, [Checkbox, '', {title: "Pin", checked: rf.dsub(['pin']), onchange: e => rf.disp(['set', {pin: e.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"],
[`.${$}-row`, ...rf.dsub(['totals']).map(([title, initial, color, used]) => ['b', {title, key: title, style: _style(color, '')}, _delta(initial+used, '')])]],
build: [['button', {onclick: $setView('points')}, "Points"],
[`.${$}-row`, ['b', `Build [${rf.dsub(['#selected'])}/${rf.dsub(['#choices'])} choices]`]],
rf.dsub(['build']) && [Checkbox, '', {title: "Fast sync (multi-tab)", checked: rf.dsub(['sync']), onchange: e => rf.disp(['set-sync', e.checked])}]],
}[ rf.dsub(['view']) ] || []), ['button', {title: "Collapse", onclick: $setHidden()}, ">"]];
let ColorSelector = (kind, name, value='white') =>
['select', {value, style: _style(value), onchange () {rf.dispatchSync([`set-${kind}-color`, name, this.value])}}, ...keys(COLORS).map(s => ['option', s])];
let PointDetails = (name, value, color='white') => [`.${$}-row`, {name}, ['button', {onclick: () => setFocus()}, '×'],
[TextInput, color, {title: "Name", value: name, size: _len(name), onchange: e => setTimeout(() => rf.dispatch(['rename-point', name, _trim(e.value)]), 10)}],
[NumberInput, color, {title: "Initial", value, style: {width: _lenEm(value)}, onchange: e => rf.dispatch(['set-initial', name, _num(e.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: e => setTimeout(() => rf.dispatch(['rename-choice', name, e.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(['choices*']).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: e => setAmount(name, e.value)}, true] :
['<>', [Checkbox, color, {checked: amount > 0, onchange: e => setAmount(name, (e.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 ChoiceGroups = ({lists}) => ['<>', ...rf.dsub(['choice-groups']).map(([k, xs], i) => r.with({key: k||i},
(!k ? [`.${$}-rows`, ...xs.map(_ChoiceView())] :
!lists ? ['p', k, ": ", ['<>', ...xs.map((x, i) => r.with({key: x.name}, ['<>', (i>0) && ", ", _ChoiceView(k, 'span', false)(x)]))], "."] :
[`.${$}-rows`, ['p', k, ":"], ['ul', ...xs.map(_ChoiceView(k, 'li'))]])))];
let Body = () => ['<>', ...({
points: [[`.${$}-rows`, ...rf.dsub(['totals']).map(([name, value, color, total]) => r.with({key: name},
(rf.dsub(['focus']) == name ? ['<>', [PointDetails, name, value, color]] :
[`.${$}-row.${$}-clickable`, {name, style: _style(color, ''), onclick: () => setFocus(name)}, name, ": ", _delta(value+total, ''), "/", _delta(value, '')])))],
[`.${$}-row`, [`button.${$}-wide`, {title: "Add", onclick: () => rf.disp(['new-point'])}, "+"]]],
choices: [[`.${$}-rows`, ((s = !rf.dsub(['edit']) && rf.dsub(['group'])) => (!s ? [ChoicesList] : [ChoiceGroups, {lists: s == 'groups'}]))()],
[`.${$}-row`, [Checkbox, '', {title: "Show selected", checked: rf.dsub(['filter']), onchange: e => rf.disp(['set', {filter: e.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: e => rf.disp(['set', {edit: !e.checked}])}]]],
build: [((k=rf.dsub(['build']), x=GM_getValue(k)) => [`.${$}-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`, ...GM_listValues().sort().map(k => (k != rf.dsub(['build'])) && [`.${$}-row`,
['button', {onclick: () => rf.dispatchSync(['export', k])}, "Export"],
(rf.dsub(['build']) ? [`.${$}-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']);
rf.disp(['set-sync', false]);
r.render([Overlay], $toggle.overlay);
}
});
if (location.hostname == "imgur.com")
(([_, id] = location.pathname.match("^/a/([0-9A-Za-z]+)")||[]) => id && GM_registerMenuCommand("Open in Cubari", () => GM_openInTab(`https://cubari.moe/read/imgur/${id}/1`)))();
if (location.hostname == "cubari.moe")
(([_, id] = location.pathname.match("^/read/imgur/([0-9A-Za-z]+)")||[]) => id && GM_registerMenuCommand("Open in Imgur", () => GM_openInTab(`https://imgur.com/a/${id}`)))();
if (["imgur.com", "imgchest.com"].includes(location.hostname)) { // CYOA mode toggler
let $ = '-cyoamode';
console.warn($);
GM_addStyle({
"imgur.com": `.${$} .NewCover, .${$} .Gallery-Sidebar, .${$} .Gallery-EngagementBar, .${$} .BottomRecirc, .${$} .Footer, .${$} .Navigation-next {display: none !important}
.${$} .Gallery {max-width: 100% !important; width: initial !important}`,
"imgchest.com": `.${$} .side-div {display: none} .${$} .container {max-width: calc(100vw - 8ex)} .${$} .post-div {max-width: 100%; flex: 1}`,
}[location.hostname]);
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)}`);
let toggle = (on = !localStorage.getItem($)) => {document.body.classList[on ? 'add' : 'remove']($);
window.dispatchEvent(new Event('resize'));
localStorage[on ? 'setItem' : 'removeItem']($, "on")};
document.body.append(Object.assign(document.createElement('button'), {innerHTML: (navigator.vendor ? "<span>CYOA</span>" : "CYOA"), className: $, onclick () {toggle()}}));
toggle(localStorage.getItem($));
}
if (["imgur.com", "imgchest.com", "ibb.co"].includes(location.hostname) || ['jpg', 'jpeg', 'png', 'webp'].some(s => location.pathname.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://agregen.gitlab.io/cyoa-viewer/v1.html?${o}`), {hash})}`};
let _fetch = unsafeWindow.fetch, urls = null, _load, load = _load = () => urls ? Promise.resolve(urls) : Promise.reject("Failed to collect URLs, try reloading the page.");
if (`${location}`.match("^https://imgur.com/a/")) {
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 span")||{}).innerText;
unsafeWindow.fetch = (url, ...args) => _fetch(url, ...args).then(x => (!url.startsWith("https://api.imgur.com/post/v1/albums/") ? x : spy(x, o => o.media.map(y => y.url))));
} else if (location.hostname == "imgur.com") {
title = () => (find(".Gallery-Title span")||{}).innerText;
setTimeout(() => {urls = findAll(".Gallery-Content img.image-placeholder").map(e => e.src)}, 2500);
} else if (location.hostname == "imgchest.com") {
title = () => (find(".card-header h4")||{}).innerText;
load = () => new Promise(resolve => {
let btn = find('button.load-all');
(btn ? (btn.click(), setTimeout(() => resolve(load()), 2500)) : resolve(findAll("#post-images img").map(e => e.src)));
});
} else if (`${location}`.match("^https://ibb.co/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 (urls = keys(o).sort().map(k => o[k]), _load())}
} else if (location.hostname == "ibb.co") {
title = () => (find("h1.viewer-title")||{}).innerText;
load = () => (urls = findAll("#image-viewer img").map(e => e.src), _load());
} 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)));
}
})();