Selection toolbar and post-state framework for the BooruKit userscript suite.
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị 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,
};
}
})();