Selection toolbar and post-state framework for the BooruKit userscript suite.
Skrip ini tidak untuk dipasang secara langsung. Ini adalah pustaka skrip lain untuk disertakan dengan direktif meta // @require https://update.sleazyfork.org/scripts/589428/1890614/BooruKit%20Deck.js
// ==UserScript==
// @name BooruKit Deck
// @namespace codeberg.org/0xdev/boorukit
// @version 0.1.0
// @description Selection toolbar and post-state framework for the BooruKit userscript suite.
// @author 0xdev
// @license LGPL-3.0-or-later
// ==/UserScript==
// THIS FILE IS AUTO-GENERATED — DO NOT MANUALLY EDIT.
// Source: boorukit/boorukit-deck.library.js
(() => {
'use strict';
const DEFAULT_THUMB_SELECTOR = 'a[href*="page=post"][href*="s=view"]';
const GELBOORU_ID_PATTERN = /[?&]id=(\d+)/;
const SITE_CONFIGS = {
'rule34.xxx': {
thumbSelector: DEFAULT_THUMB_SELECTOR,
galleryRootSelector: '#post-list',
idPattern: GELBOORU_ID_PATTERN,
galleryUrlKind: 'query',
},
'safebooru.org': {
thumbSelector: DEFAULT_THUMB_SELECTOR,
galleryRootSelector: '#post-list',
idPattern: GELBOORU_ID_PATTERN,
galleryUrlKind: 'query',
},
'gelbooru.com': {
thumbSelector: DEFAULT_THUMB_SELECTOR,
galleryRootSelector: '#post-list',
idPattern: GELBOORU_ID_PATTERN,
galleryUrlKind: 'query',
},
'hypnohub.net': {
thumbSelector: DEFAULT_THUMB_SELECTOR,
galleryRootSelector: '#post-list',
idPattern: GELBOORU_ID_PATTERN,
galleryUrlKind: 'query',
},
'e621.net': {
thumbSelector: 'article.thumbnail a[href*="/posts/"]',
galleryRootSelector: '#posts-container',
idPattern: /\/posts\/(\d+)/,
galleryUrlKind: 'path',
},
'e926.net': {
thumbSelector: 'article.thumbnail a[href*="/posts/"]',
galleryRootSelector: '#posts-container',
idPattern: /\/posts\/(\d+)/,
galleryUrlKind: 'path',
},
};
const PAGE_CSS = `.boorukit-thumb { position: relative; }
.boorukit-selected { outline: 3px solid #35c46a !important; }
.boorukit-failed { outline-color: #d33 !important; }
.boorukit-selection-armed #thumbPlusPreviewContainer {
display: none !important;
}`;
const SHADOW_CSS = `
:host {
--bk-ink: #151a21;
--bk-panel: #222a35;
--bk-panel-raised: #2c3643;
--bk-text: #f2f6fb;
--bk-muted: #9eabba;
--bk-signal: #58b8e8;
--bk-selected: #45c879;
--bk-danger: #f06d78;
position: fixed;
right: 16px;
bottom: 16px;
z-index: 900;
color: var(--bk-text);
font-family: system-ui, sans-serif;
}
button, input { font: inherit; }
button {
min-height: 36px;
border: 1px solid #465568;
border-radius: 5px;
padding: 6px 11px;
background: var(--bk-panel-raised);
color: var(--bk-text);
cursor: pointer;
}
button:hover { border-color: var(--bk-signal); }
button:focus-visible, input:focus-visible {
outline: 3px solid var(--bk-signal);
outline-offset: 2px;
}
.launcher {
border-color: #536579;
background: var(--bk-panel);
font-family: "Aptos Narrow", "Arial Narrow", "Segoe UI Variable", sans-serif;
font-weight: 700;
letter-spacing: .04em;
}
.instrument {
box-sizing: border-box;
display: grid;
gap: 7px;
width: max-content;
max-width: min(760px, calc(100vw - 24px));
border: 1px solid #435064;
border-radius: 8px;
padding: 8px;
background: var(--bk-panel);
box-shadow: 0 4px 14px rgb(0 0 0 / 28%);
}
.instrument[hidden], .settings[hidden], .progress[hidden],
.panel-content[hidden] { display: none; }
.shell-row, .feature-row, .progress {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
}
.feature-row:empty { display: none; }
.tally {
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px 0 5px;
border: 1px solid #435064;
border-radius: 5px;
background: var(--bk-ink);
color: var(--bk-muted);
font-family: ui-monospace, "Cascadia Mono", monospace;
}
.tally strong { color: var(--bk-text); }
.tally-mark {
display: grid;
grid-template-columns: repeat(2, 5px);
grid-template-rows: repeat(2, 5px);
gap: 2px;
}
.tally-mark > span { background: var(--bk-selected); }
.settings, .panel-content {
border-top: 1px solid #435064;
padding-top: 7px;
}
.settings-list { display: grid; gap: 6px; }
.setting {
display: flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--bk-text);
}
.setting-note, .status { color: var(--bk-muted); font-size: 12px; }
.status[data-state="unavailable"] { color: var(--bk-danger); }
.progress {
border-top: 1px solid #435064;
padding-top: 7px;
}
.progress-label {
font-family: "Aptos Narrow", "Arial Narrow", "Segoe UI Variable", sans-serif;
font-weight: 700;
}
.progress-count { font-family: ui-monospace, "Cascadia Mono", monospace; }
@media (max-width: 560px) {
:host { right: 8px; bottom: 8px; }
.instrument { max-width: calc(100vw - 16px); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0s !important;
animation-duration: 0s !important;
}
}`;
function isGalleryPage(locationLike, kind) {
if (kind === 'path') {
return locationLike.pathname === '/posts'
|| locationLike.pathname === '/posts/';
}
const params = new URLSearchParams(locationLike.search);
return params.get('page') === 'post' && params.get('s') === 'list';
}
function postIdFromHref(href, pattern) {
const match = pattern.exec(href);
return match ? match[1] : null;
}
function md5FromThumbSrc(src) {
const match = /thumbnail_([0-9a-fA-F]{32})/.exec(src);
return match ? match[1].toLowerCase() : null;
}
function resolveSiteConfig(hostname) {
const normalized = hostname.toLowerCase().replace(/^www\./, '');
const known = SITE_CONFIGS[normalized];
if (known)
return known;
if (normalized.endsWith('.booru.org')) {
return {
thumbSelector: DEFAULT_THUMB_SELECTOR,
galleryRootSelector: '#post-list',
idPattern: GELBOORU_ID_PATTERN,
galleryUrlKind: 'query',
};
}
return null;
}
function scanThumbs(root, config) {
const thumbs = [];
const seen = new Set();
for (const anchor of Array.from(root.querySelectorAll(config.thumbSelector))) {
const image = anchor.querySelector('img');
if (!image)
continue;
const id = postIdFromHref(anchor.getAttribute('href') || '', config.idPattern);
if (!id || seen.has(id))
continue;
seen.add(id);
thumbs.push({
id,
md5: md5FromThumbSrc(image.getAttribute('src') || '') || '',
thumbUrl: image.src,
anchor,
});
}
return thumbs;
}
function createSelection(onChange) {
const ids = new Set();
return {
toggle(id) {
if (ids.has(id))
ids.delete(id);
else
ids.add(id);
onChange();
},
has: (id) => ids.has(id),
ids: () => [...ids],
clear() {
ids.clear();
onChange();
},
size: () => ids.size,
};
}
function createThumbClickHandler(thumbs, selection, config, isModeOn) {
return (event) => {
if (!isModeOn())
return;
if (event.button !== 0
|| event.ctrlKey
|| event.metaKey
|| event.shiftKey
|| event.altKey)
return;
const target = event.target;
const anchor = target && typeof target.closest === 'function'
? target.closest('a')
: null;
if (!anchor)
return;
const id = postIdFromHref(anchor.getAttribute('href') || '', config.idPattern);
const thumb = id ? thumbs.find((candidate) => candidate.id === id) : null;
if (!thumb)
return;
event.preventDefault();
event.stopPropagation();
selection.toggle(thumb.id);
thumb.anchor.classList.add('boorukit-thumb');
thumb.anchor.classList.toggle('boorukit-selected', selection.has(thumb.id));
};
}
function applyThumbState(anchor, state, reason) {
anchor.classList.add('boorukit-thumb');
if (state === 'failed') {
anchor.classList.add('boorukit-failed');
anchor.title = `feature failed: ${reason ?? 'unknown'}`;
return;
}
anchor.classList.remove('boorukit-failed');
anchor.removeAttribute('title');
}
function createDeckController(context, doc = document) {
const resolvedConfig = resolveSiteConfig(context.page.hostname);
if (!resolvedConfig)
throw new Error('Deck does not support this site');
const config = resolvedConfig;
const addStyle = globalThis.GM_addStyle;
addStyle?.(PAGE_CSS);
const host = doc.createElement('div');
host.setAttribute('data-boorukit-deck', '');
const shadowRoot = host.attachShadow({ mode: 'open' });
const style = doc.createElement('style');
style.textContent = SHADOW_CSS;
const launcher = doc.createElement('button');
launcher.type = 'button';
launcher.className = 'launcher';
launcher.textContent = 'BooruKit';
launcher.setAttribute('aria-label', 'Open BooruKit selection controls');
const instrument = doc.createElement('section');
instrument.className = 'instrument';
instrument.hidden = true;
instrument.setAttribute('aria-label', 'BooruKit selection controls');
const shellRow = doc.createElement('div');
shellRow.className = 'shell-row';
const tally = doc.createElement('span');
tally.className = 'tally';
const tallyMark = doc.createElement('span');
tallyMark.className = 'tally-mark';
tallyMark.setAttribute('aria-hidden', 'true');
for (let index = 0; index < 4; index += 1) {
tallyMark.appendChild(doc.createElement('span'));
}
const count = doc.createElement('strong');
count.setAttribute('data-selection-count', '');
count.textContent = '0';
const countLabel = doc.createElement('span');
countLabel.textContent = 'selected';
tally.append(tallyMark, count, countLabel);
function commandButton(command, label) {
const button = doc.createElement('button');
button.type = 'button';
button.setAttribute('data-command', command);
button.textContent = label;
return button;
}
const selectPage = commandButton('select-page', 'Select page');
const clear = commandButton('clear', 'Clear');
const settingsButton = commandButton('settings', 'Settings');
const closeButton = commandButton('close', 'Close');
shellRow.append(tally, selectPage, clear, settingsButton, closeButton);
const featureRow = doc.createElement('div');
featureRow.className = 'feature-row';
const settings = doc.createElement('section');
settings.className = 'settings';
settings.hidden = true;
settings.setAttribute('aria-label', 'BooruKit settings');
const settingsList = doc.createElement('div');
settingsList.className = 'settings-list';
settings.appendChild(settingsList);
const panelContent = doc.createElement('section');
panelContent.className = 'panel-content';
panelContent.setAttribute('data-panel-content', '');
panelContent.hidden = true;
const progress = doc.createElement('section');
progress.className = 'progress';
progress.setAttribute('data-progress', '');
progress.setAttribute('aria-live', 'polite');
progress.hidden = true;
const progressLabel = doc.createElement('span');
progressLabel.className = 'progress-label';
progressLabel.setAttribute('data-progress-label', '');
const progressCount = doc.createElement('span');
progressCount.className = 'progress-count';
progressCount.setAttribute('data-progress-count', '');
const cancel = commandButton('cancel', 'Cancel');
progress.append(progressLabel, progressCount, cancel);
instrument.append(shellRow, featureRow, settings, panelContent, progress);
shadowRoot.append(style, launcher, instrument);
doc.body.appendChild(host);
const selectionListeners = new Set();
const currentThumbsById = new Map();
const selectedRefsById = new Map();
const itemStatesById = new Map();
const thumbs = [];
let modeOn = false;
let destroyed = false;
let observer = null;
let reconcileTimer = null;
let syncSelection = () => { };
const selection = createSelection(() => syncSelection());
function selectionSnapshot() {
const snapshot = [];
for (const id of selection.ids()) {
const selected = selectedRefsById.get(id);
if (selected)
snapshot.push(selected);
}
return Object.freeze(snapshot);
}
function selectedPostRef(thumb) {
return Object.freeze({
site: context.page.normalizedHostname,
id: thumb.id,
md5: thumb.md5,
thumbUrl: thumb.thumbUrl,
});
}
function refreshThumbs() {
const galleryRoot = doc.querySelector(config.galleryRootSelector);
const scanned = scanThumbs(galleryRoot ?? doc, config);
for (const thumb of thumbs) {
thumb.anchor.classList.remove('boorukit-thumb', 'boorukit-selected', 'boorukit-failed');
thumb.anchor.removeAttribute('title');
}
thumbs.splice(0, thumbs.length, ...scanned);
currentThumbsById.clear();
for (const thumb of scanned) {
currentThumbsById.set(thumb.id, thumb);
if (selection.has(thumb.id)) {
selectedRefsById.set(thumb.id, selectedPostRef(thumb));
}
}
}
function decorateThumbs() {
for (const thumb of thumbs) {
thumb.anchor.classList.add('boorukit-thumb');
thumb.anchor.classList.toggle('boorukit-selected', modeOn && selection.has(thumb.id));
const itemState = itemStatesById.get(thumb.id);
applyThumbState(thumb.anchor, itemState?.state ?? 'idle', itemState?.reason ?? null);
}
}
syncSelection = () => {
for (const id of [...selectedRefsById.keys()]) {
if (!selection.has(id))
selectedRefsById.delete(id);
}
for (const id of selection.ids()) {
const current = currentThumbsById.get(id);
if (current)
selectedRefsById.set(id, selectedPostRef(current));
}
const visibleSelectedCount = thumbs.reduce((total, thumb) => total + (selection.has(thumb.id) ? 1 : 0), 0);
count.textContent = String(selection.size());
selectPage.hidden = thumbs.length > 0
&& visibleSelectedCount >= thumbs.length;
decorateThumbs();
const snapshot = selectionSnapshot();
for (const listener of selectionListeners)
listener(snapshot);
};
function stopObserver() {
observer?.disconnect();
observer = null;
if (reconcileTimer !== null) {
doc.defaultView?.clearTimeout(reconcileTimer);
reconcileTimer = null;
}
}
function startObserver() {
const view = doc.defaultView;
const galleryRoot = doc.querySelector(config.galleryRootSelector);
if (!view || !galleryRoot || typeof view.MutationObserver !== 'function') {
return;
}
observer = new view.MutationObserver(() => {
if (reconcileTimer !== null)
return;
reconcileTimer = view.setTimeout(() => {
reconcileTimer = null;
if (!modeOn || destroyed)
return;
refreshThumbs();
syncSelection();
}, 40);
});
observer.observe(galleryRoot, { childList: true, subtree: true });
}
const clickHandler = createThumbClickHandler(thumbs, selection, config, () => modeOn);
doc.addEventListener('click', clickHandler, true);
const actions = new Map();
const panels = new Map();
const settingEntries = new Map();
const statuses = new Map();
let panelCleanup = null;
let activePanelId = null;
const selectionApi = Object.freeze({
snapshot: selectionSnapshot,
has(site, id) {
const normalized = site.toLowerCase().replace(/^www\./, '');
return normalized === context.page.normalizedHostname
&& selection.has(id);
},
size: () => selection.size(),
subscribe(listener) {
selectionListeners.add(listener);
return () => { selectionListeners.delete(listener); };
},
});
const api = Object.freeze({
selection: selectionApi,
registerAction(definition) {
const button = doc.createElement('button');
button.type = 'button';
button.setAttribute('data-action', definition.id);
button.textContent = definition.label;
button.addEventListener('click', () => {
void Promise.resolve(definition.run(selectionSnapshot())).catch((error) => context.diagnostics.report('deck', error));
});
actions.set(definition.id, { definition, button });
featureRow.appendChild(button);
return () => {
const current = actions.get(definition.id);
if (current?.button === button)
actions.delete(definition.id);
button.remove();
};
},
registerPanel(definition) {
if (panels.has(definition.id)) {
throw new Error(`duplicate panel id: ${definition.id}`);
}
const button = doc.createElement('button');
button.type = 'button';
button.setAttribute('data-panel', definition.id);
button.textContent = definition.label;
button.addEventListener('click', () => {
panelCleanup?.();
panelCleanup = null;
activePanelId = null;
panelContent.replaceChildren();
const cleanup = definition.render(panelContent);
panelCleanup = typeof cleanup === 'function' ? cleanup : null;
activePanelId = definition.id;
panelContent.hidden = false;
});
panels.set(definition.id, { definition, button });
featureRow.appendChild(button);
return () => {
const current = panels.get(definition.id);
if (current?.button === button)
panels.delete(definition.id);
button.remove();
if (current?.button === button
&& activePanelId === definition.id) {
panelCleanup?.();
panelCleanup = null;
activePanelId = null;
panelContent.replaceChildren();
panelContent.hidden = true;
}
};
},
registerSetting(definition) {
const label = doc.createElement('label');
label.className = 'setting';
const input = doc.createElement('input');
input.type = 'checkbox';
input.setAttribute('data-setting', definition.id);
const name = doc.createElement('span');
const note = doc.createElement('small');
note.className = 'setting-note';
label.append(input, name, note);
const entry = { definition, label, name, note, input };
const render = () => {
name.textContent = entry.definition.label;
input.checked = entry.definition.checked;
note.textContent = entry.definition.reloadRequired
? 'Reload required'
: '';
};
input.addEventListener('change', () => {
void Promise.resolve(entry.definition.onChange(input.checked)).catch((error) => context.diagnostics.report('deck', error));
});
settingEntries.set(definition.id, entry);
render();
settingsList.appendChild(label);
return Object.freeze({
update(next) {
entry.definition = next;
render();
},
dispose() {
if (settingEntries.get(definition.id) === entry) {
settingEntries.delete(definition.id);
}
label.remove();
},
});
},
setItemState(id, state, reason) {
itemStatesById.set(id, Object.freeze({ state, reason }));
const current = currentThumbsById.get(id);
if (modeOn && current) {
applyThumbState(current.anchor, state, reason ?? null);
}
},
setFeatureStatus(featureId, status, reason) {
statuses.get(featureId)?.remove();
statuses.delete(featureId);
if (status === 'available')
return;
const message = doc.createElement('span');
message.className = 'status';
message.setAttribute('data-feature-status', featureId);
message.setAttribute('data-state', status);
const label = featureId === 'grabber' ? 'Grabber' : 'Pool Party';
message.textContent = `${label} unavailable${reason ? `: ${reason}` : ''}`;
statuses.set(featureId, message);
featureRow.appendChild(message);
},
});
function open() {
if (destroyed || modeOn)
return;
modeOn = true;
refreshThumbs();
startObserver();
doc.body.classList.add('boorukit-selection-armed');
launcher.hidden = true;
instrument.hidden = false;
syncSelection();
}
function close() {
if (!modeOn)
return;
modeOn = false;
stopObserver();
doc.body.classList.remove('boorukit-selection-armed');
instrument.hidden = true;
launcher.hidden = false;
for (const thumb of thumbs) {
thumb.anchor.classList.remove('boorukit-thumb', 'boorukit-selected', 'boorukit-failed');
thumb.anchor.removeAttribute('title');
}
}
launcher.addEventListener('click', open);
closeButton.addEventListener('click', close);
selectPage.addEventListener('click', () => {
for (const thumb of thumbs) {
if (!selection.has(thumb.id))
selection.toggle(thumb.id);
}
});
clear.addEventListener('click', () => {
itemStatesById.clear();
selection.clear();
for (const thumb of thumbs)
applyThumbState(thumb.anchor, 'idle', null);
});
settingsButton.addEventListener('click', () => {
settings.hidden = !settings.hidden;
});
cancel.addEventListener('click', () => context.tasks.cancel());
const unsubscribeProgress = context.tasks.subscribe((state) => {
if (!state) {
progress.hidden = true;
progressLabel.textContent = '';
progressCount.textContent = '';
return;
}
progress.hidden = false;
progressLabel.textContent = state.label;
progressCount.textContent = `${state.completed}/${state.total}`;
});
function destroy() {
if (destroyed)
return;
close();
destroyed = true;
unsubscribeProgress();
panelCleanup?.();
panelCleanup = null;
activePanelId = null;
selectionListeners.clear();
doc.removeEventListener('click', clickHandler, true);
host.remove();
}
return Object.freeze({ api, shadowRoot, open, close, destroy });
}
function createDeckDescriptor() {
return {
kind: 'deck',
id: 'deck',
label: 'Deck',
apiVersion: 1,
defaultEnabled: true,
required: true,
supports: (page) => {
const config = resolveSiteConfig(page.hostname);
return config !== null
&& isGalleryPage(page.url, config.galleryUrlKind);
},
activate: (context) => createDeckController(context).api,
};
}
function registerDeck(runtime) {
runtime.register(createDeckDescriptor());
}
function registerGlobalDeck() {
const key = Symbol.for('codeberg.org/0xdev/boorukit/runtime/v1');
const runtimes = globalThis;
const runtime = runtimes[key];
if (!runtime || runtime.apiVersion !== 1) {
throw new Error('BooruKit Core API 1 is not available');
}
runtime.register(createDeckDescriptor());
}
if (typeof window !== 'undefined')
registerGlobalDeck();
if (typeof window === 'undefined'
&& typeof module !== 'undefined'
&& module.exports) {
module.exports = {
PAGE_CSS,
applyThumbState,
createDeckController,
createDeckDescriptor,
createSelection,
createThumbClickHandler,
isGalleryPage,
postIdFromHref,
registerDeck,
resolveSiteConfig,
scanThumbs,
};
}
})();