mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-23 20:18:00 +01:00
add scripts
This commit is contained in:
parent
509d53dbee
commit
a40d31e9cd
48 changed files with 4456 additions and 13 deletions
|
@ -1,15 +1,17 @@
|
|||
// We need to import the CSS so that webpack will load it.
|
||||
// The MiniCssExtractPlugin is used to separate it out into
|
||||
// its own CSS file.
|
||||
|
||||
// webpack automatically bundles all modules in your
|
||||
// entry points. Those entry points can be configured
|
||||
// in "webpack.config.js".
|
||||
//
|
||||
// Import dependencies
|
||||
// This is a manifest file that'll be compiled into including all the files listed below.
|
||||
// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
|
||||
// be included in the compiled file accessible from http://example.com/assets/application.js
|
||||
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
|
||||
// the compiled file.
|
||||
//
|
||||
|
||||
// Import local files
|
||||
//
|
||||
// Local files can be imported directly using relative paths, for example:
|
||||
// import socket from "./socket"
|
||||
// Third-party code, polyfills
|
||||
import './vendor/promise.polyfill';
|
||||
import './vendor/fetch.polyfill';
|
||||
import './vendor/closest.polyfill';
|
||||
import './vendor/customevent.polyfill';
|
||||
import './vendor/es6.polyfill';
|
||||
|
||||
// Our code
|
||||
import './ujs';
|
||||
import './when-ready';
|
||||
|
|
156
assets/js/autocomplete.js
Normal file
156
assets/js/autocomplete.js
Normal file
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Autocomplete.
|
||||
*/
|
||||
|
||||
const cache = {};
|
||||
let inputField, originalTerm;
|
||||
|
||||
function removeParent() {
|
||||
const parent = document.querySelector('.autocomplete');
|
||||
if (parent) parent.parentNode.removeChild(parent);
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
const selected = document.querySelector('.autocomplete__item--selected');
|
||||
if (selected) selected.classList.remove('autocomplete__item--selected');
|
||||
}
|
||||
|
||||
function changeSelected(firstOrLast, current, sibling) {
|
||||
if (current && sibling) { // if the currently selected item has a sibling, move selection to it
|
||||
current.classList.remove('autocomplete__item--selected');
|
||||
sibling.classList.add('autocomplete__item--selected');
|
||||
}
|
||||
else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term
|
||||
inputField.value = originalTerm;
|
||||
removeSelected();
|
||||
}
|
||||
else if (firstOrLast) { // if no item in the list is selected, select the first or last
|
||||
firstOrLast.classList.add('autocomplete__item--selected');
|
||||
}
|
||||
}
|
||||
|
||||
function keydownHandler(event) {
|
||||
const selected = document.querySelector('.autocomplete__item--selected'),
|
||||
firstItem = document.querySelector('.autocomplete__item:first-of-type'),
|
||||
lastItem = document.querySelector('.autocomplete__item:last-of-type');
|
||||
|
||||
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp
|
||||
if (event.keyCode === 40) changeSelected(firstItem, selected, selected && selected.nextSibling); // ArrowDown
|
||||
if (event.keyCode === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
|
||||
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown
|
||||
const newSelected = document.querySelector('.autocomplete__item--selected');
|
||||
if (newSelected) inputField.value = newSelected.dataset.value;
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function createItem(list, suggestion) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'autocomplete__item';
|
||||
|
||||
item.textContent = suggestion.label;
|
||||
item.dataset.value = suggestion.value;
|
||||
|
||||
item.addEventListener('mouseover', () => {
|
||||
removeSelected();
|
||||
item.classList.add('autocomplete__item--selected');
|
||||
inputField.value = item.dataset.value;
|
||||
});
|
||||
|
||||
item.addEventListener('mouseout', () => {
|
||||
inputField.value = originalTerm;
|
||||
removeSelected();
|
||||
});
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
inputField.dispatchEvent(
|
||||
new CustomEvent('autocomplete', {
|
||||
detail: {
|
||||
type: 'click',
|
||||
label: suggestion.label,
|
||||
value: suggestion.value,
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
function createList(suggestions) {
|
||||
const parent = document.querySelector('.autocomplete'),
|
||||
list = document.createElement('ul');
|
||||
list.className = 'autocomplete__list';
|
||||
|
||||
suggestions.forEach(suggestion => createItem(list, suggestion));
|
||||
|
||||
parent.appendChild(list);
|
||||
}
|
||||
|
||||
function createParent() {
|
||||
const parent = document.createElement('div');
|
||||
parent.className = 'autocomplete';
|
||||
|
||||
// Position the parent below the inputfield
|
||||
parent.style.position = 'absolute';
|
||||
parent.style.left = `${inputField.offsetLeft}px`;
|
||||
// Take the inputfield offset, add its height and subtract the amount by which the parent element has scrolled
|
||||
parent.style.top = `${inputField.offsetTop + inputField.offsetHeight - inputField.parentNode.scrollTop}px`;
|
||||
|
||||
// We append the parent at the end of body
|
||||
document.body.appendChild(parent);
|
||||
}
|
||||
|
||||
function showAutocomplete(suggestions, targetInput) {
|
||||
// Remove old autocomplete suggestions
|
||||
removeParent();
|
||||
|
||||
// Save suggestions in cache
|
||||
cache[targetInput.value] = suggestions;
|
||||
|
||||
// If the input target is not empty, still visible, and suggestions were found
|
||||
if (targetInput.value && targetInput.style.display !== 'none' && suggestions.length) {
|
||||
createParent();
|
||||
createList(suggestions);
|
||||
inputField.addEventListener('keydown', keydownHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function getSuggestions() {
|
||||
return fetch(inputField.dataset.acSource + inputField.value).then(response => response.json());
|
||||
}
|
||||
|
||||
function listenAutocomplete() {
|
||||
let timeout;
|
||||
|
||||
document.addEventListener('input', event => {
|
||||
removeParent();
|
||||
|
||||
window.clearTimeout(timeout);
|
||||
// Use a timeout to delay requests until the user has stopped typing
|
||||
timeout = window.setTimeout(() => {
|
||||
inputField = event.target;
|
||||
originalTerm = inputField.value;
|
||||
const {ac, acMinLength} = inputField.dataset;
|
||||
|
||||
if (ac && (inputField.value.length >= acMinLength)) {
|
||||
|
||||
if (cache[inputField.value]) {
|
||||
showAutocomplete(cache[inputField.value], event.target);
|
||||
}
|
||||
else {
|
||||
// inputField could get overwritten while the suggestions are being fetched - use event.target
|
||||
getSuggestions().then(suggestions => showAutocomplete(suggestions, event.target));
|
||||
}
|
||||
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// If there's a click outside the inputField, remove autocomplete
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target && event.target !== inputField) removeParent();
|
||||
});
|
||||
}
|
||||
|
||||
export { listenAutocomplete };
|
114
assets/js/booru.js
Normal file
114
assets/js/booru.js
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { $ } from './utils/dom';
|
||||
import parseSearch from './match_query';
|
||||
import store from './utils/store';
|
||||
|
||||
/* Store a tag locally, marking the retrieval time */
|
||||
function persistTag(tagData) {
|
||||
tagData.fetchedAt = new Date().getTime() / 1000;
|
||||
store.set(`bor_tags_${tagData.id}`, tagData);
|
||||
}
|
||||
|
||||
function isStale(tag) {
|
||||
const now = new Date().getTime() / 1000;
|
||||
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800);
|
||||
}
|
||||
|
||||
function clearTags() {
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.substring(0, 9) === 'bor_tags_') {
|
||||
store.remove(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Returns a single tag, or a dummy tag object if we don't know about it yet */
|
||||
function getTag(tagId) {
|
||||
const stored = store.get(`bor_tags_${tagId}`);
|
||||
|
||||
if (stored) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
return {
|
||||
id: tagId,
|
||||
name: '(unknown tag)',
|
||||
images: 0,
|
||||
spoiler_image_uri: null,
|
||||
};
|
||||
}
|
||||
|
||||
/* Fetches lots of tags in batches and stores them locally */
|
||||
function fetchAndPersistTags(tagIds) {
|
||||
const chunk = 40;
|
||||
for (let i = 0; i < tagIds.length; i += chunk) {
|
||||
const ids = tagIds.slice(i, i + chunk);
|
||||
|
||||
fetch(`${window.booru.apiEndpoint}tags/fetch_many.json?ids[]=${ids.join('&ids[]=')}`)
|
||||
.then(response => response.json())
|
||||
.then(data => data.tags.forEach(tag => persistTag(tag)));
|
||||
}
|
||||
}
|
||||
|
||||
/* Figure out which tags in the list we don't know about */
|
||||
function fetchNewOrStaleTags(tagIds) {
|
||||
const fetchIds = [];
|
||||
|
||||
tagIds.forEach(t => {
|
||||
const stored = store.get(`bor_tags_${t}`);
|
||||
if (!stored || isStale(stored)) {
|
||||
fetchIds.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
fetchAndPersistTags(fetchIds);
|
||||
}
|
||||
|
||||
function verifyTagsVersion(latest) {
|
||||
if (store.get('bor_tags_version') !== latest) {
|
||||
clearTags();
|
||||
store.set('bor_tags_version', latest);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const tags = window.booru.spoileredTagList
|
||||
.concat(window.booru.hiddenTagList)
|
||||
.filter((a, b, c) => c.indexOf(a) === b);
|
||||
|
||||
verifyTagsVersion(window.booru.tagsVersion);
|
||||
fetchNewOrStaleTags(tags);
|
||||
}
|
||||
|
||||
function unmarshal(data) {
|
||||
try { return JSON.parse(data); }
|
||||
catch (_) { return data; }
|
||||
}
|
||||
|
||||
function loadBooruData() {
|
||||
const booruData = document.querySelector('.js-datastore').dataset;
|
||||
|
||||
// Assign all elements to booru because lazy
|
||||
for (const prop in booruData) {
|
||||
window.booru[prop] = unmarshal(booruData[prop]);
|
||||
}
|
||||
|
||||
window.booru.hiddenFilter = parseSearch(window.booru.hiddenFilter);
|
||||
window.booru.spoileredFilter = parseSearch(window.booru.spoileredFilter);
|
||||
|
||||
// Fetch tag metadata and set up filtering
|
||||
initializeFilters();
|
||||
|
||||
// CSRF
|
||||
window.booru.csrfToken = $('meta[name="csrf-token"]').content;
|
||||
window.booru.csrfParam = $('meta[name="csrf-param"]').content;
|
||||
}
|
||||
|
||||
function BooruOnRails() {
|
||||
this.apiEndpoint = '/api/v2/';
|
||||
this.hiddenTag = '/tagblocked.svg';
|
||||
this.tagsVersion = 5;
|
||||
}
|
||||
|
||||
window.booru = new BooruOnRails();
|
||||
|
||||
export { getTag, loadBooruData };
|
109
assets/js/boorujs.js
Normal file
109
assets/js/boorujs.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* BoorUJS
|
||||
*
|
||||
* Apply event-based actions through data-* attributes. The attributes are structured like so: [data-event-action]
|
||||
*/
|
||||
|
||||
import { $ } from './utils/dom';
|
||||
import { fetchHtml, handleError } from './utils/requests';
|
||||
import { showBlock } from './utils/image';
|
||||
import { addTag } from './tagsinput';
|
||||
import { toggleSubscription, markRead } from './notifications';
|
||||
|
||||
// Event types and any qualifying conditions - return true to not run action
|
||||
const types = {
|
||||
click(event) { return event.button !== 0; /* Left-click only */ },
|
||||
|
||||
change() { /* No qualifier */ },
|
||||
|
||||
fetchcomplete() { /* No qualifier */ },
|
||||
};
|
||||
|
||||
const actions = {
|
||||
hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); },
|
||||
|
||||
show(data) { selectorCb(data.base, data.value, el => el.classList.remove('hidden')); },
|
||||
|
||||
toggle(data) { selectorCb(data.base, data.value, el => el.classList.toggle('hidden')); },
|
||||
|
||||
submit(data) { selectorCb(data.base, data.value, el => el.submit()); },
|
||||
|
||||
disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); },
|
||||
|
||||
copy(data) { document.querySelector(data.value).select(); document.execCommand('copy'); },
|
||||
|
||||
inputvalue(data) { document.querySelector(data.value).value = data.el.dataset.setValue; },
|
||||
|
||||
selectvalue(data) { document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; },
|
||||
|
||||
focus(data) { document.querySelector(data.value).focus(); },
|
||||
|
||||
preventdefault() { /* The existence of this entry is enough */ },
|
||||
|
||||
addtag(data) {
|
||||
addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName);
|
||||
},
|
||||
|
||||
tab(data) {
|
||||
const block = data.el.parentNode.parentNode,
|
||||
newTab = $(`.block__tab[data-tab="${data.value}"]`),
|
||||
loadTab = data.el.dataset.loadTab;
|
||||
|
||||
// Switch tab
|
||||
const selectedTab = block.querySelector('.selected');
|
||||
if (selectedTab) {
|
||||
selectedTab.classList.remove('selected');
|
||||
}
|
||||
data.el.classList.add('selected');
|
||||
|
||||
// Switch contents
|
||||
this.hide({ base: block, value: '.block__tab' });
|
||||
this.show({ base: block, value: `.block__tab[data-tab="${data.value}"]` });
|
||||
|
||||
// If the tab has a 'data-load-tab' attribute, load and insert the content
|
||||
if (loadTab && !newTab.dataset.loaded) {
|
||||
fetchHtml(loadTab)
|
||||
.then(handleError)
|
||||
.then(response => response.text())
|
||||
.then(response => newTab.innerHTML = response)
|
||||
.then(() => newTab.dataset.loaded = true)
|
||||
.catch(() => newTab.textContent = 'Error!');
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
unfilter(data) { showBlock(data.el.closest('.image-show-container')); },
|
||||
|
||||
toggleSubscription,
|
||||
|
||||
markRead,
|
||||
|
||||
};
|
||||
|
||||
// Use this function to apply a callback to elements matching the selectors
|
||||
function selectorCb(base = document, selector, cb) {
|
||||
[].forEach.call(base.querySelectorAll(selector), cb);
|
||||
}
|
||||
|
||||
function matchAttributes(event) {
|
||||
if (!types[event.type](event)) {
|
||||
for (const action in actions) {
|
||||
|
||||
const attr = `data-${event.type}-${action.toLowerCase()}`,
|
||||
el = event.target && event.target.closest(`[${attr}]`),
|
||||
value = el && el.getAttribute(attr);
|
||||
|
||||
if (el) {
|
||||
// Return true if you don't want to preventDefault
|
||||
actions[action]({ attr, el, value }) || event.preventDefault();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerEvents() {
|
||||
for (const type in types) document.addEventListener(type, matchAttributes);
|
||||
}
|
||||
|
||||
export { registerEvents };
|
73
assets/js/burger.js
Normal file
73
assets/js/burger.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Hamburger menu.
|
||||
*/
|
||||
|
||||
function switchClasses(element, oldClass, newClass) {
|
||||
element.classList.remove(oldClass);
|
||||
element.classList.add(newClass);
|
||||
}
|
||||
|
||||
function open(burger, content, body, root) {
|
||||
switchClasses(content, 'close', 'open');
|
||||
switchClasses(burger, 'close', 'open');
|
||||
|
||||
root.classList.add('no-overflow-x');
|
||||
body.classList.add('no-overflow');
|
||||
}
|
||||
|
||||
function close(burger, content, body, root) {
|
||||
switchClasses(content, 'open', 'close');
|
||||
switchClasses(burger, 'open', 'close');
|
||||
|
||||
/* the CSS animation closing the menu finishes in 300ms */
|
||||
setTimeout(() => {
|
||||
root.classList.remove('no-overflow-x');
|
||||
body.classList.remove('no-overflow');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function copyUserLinksTo(burger) {
|
||||
const copy = links => {
|
||||
burger.appendChild(document.createElement('hr'));
|
||||
|
||||
[].slice.call(links).forEach(link => {
|
||||
const burgerLink = link.cloneNode(true);
|
||||
|
||||
burgerLink.className = '';
|
||||
burger.appendChild(burgerLink);
|
||||
});
|
||||
};
|
||||
|
||||
const linksContainers = document.querySelectorAll('.js-burger-links');
|
||||
|
||||
[].slice.call(linksContainers).forEach(container => copy(container.children));
|
||||
}
|
||||
|
||||
function setupBurgerMenu() {
|
||||
const burger = document.getElementById('burger');
|
||||
const toggle = document.getElementById('js-burger-toggle');
|
||||
const content = document.getElementById('container');
|
||||
const body = document.body;
|
||||
const root = document.documentElement;
|
||||
|
||||
copyUserLinksTo(burger);
|
||||
|
||||
toggle.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (content.classList.contains('open')) {
|
||||
close(burger, content, body, root);
|
||||
}
|
||||
else {
|
||||
open(burger, content, body, root);
|
||||
}
|
||||
});
|
||||
content.addEventListener('click', () => {
|
||||
if (content.classList.contains('open')) {
|
||||
close(burger, content, body, root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { setupBurgerMenu };
|
11
assets/js/cable.js
Normal file
11
assets/js/cable.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
// Action Cable provides the framework to deal with WebSockets in Rails.
|
||||
// You can generate new channels where WebSocket features live using the rails generate channel command.
|
||||
let cable;
|
||||
|
||||
function setupCable() {
|
||||
if (window.booru.userIsSignedIn) {
|
||||
cable = ActionCable.createConsumer();
|
||||
}
|
||||
}
|
||||
|
||||
export { cable, setupCable };
|
33
assets/js/captcha.js
Normal file
33
assets/js/captcha.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Fetch captchas.
|
||||
*/
|
||||
import { $$, hideEl } from './utils/dom';
|
||||
import { fetchJson, handleError } from './utils/requests';
|
||||
|
||||
function insertCaptcha(checkbox) {
|
||||
// Also hide any associated labels
|
||||
checkbox.checked = false;
|
||||
hideEl(checkbox);
|
||||
hideEl($$(`label[for="${checkbox.id}"]`));
|
||||
|
||||
fetchJson('POST', '/captchas')
|
||||
.then(handleError)
|
||||
.then(r => r.text())
|
||||
.then(r => {
|
||||
checkbox.insertAdjacentHTML('afterend', r);
|
||||
checkbox.parentElement.removeChild(checkbox);
|
||||
}).catch(() => {
|
||||
checkbox.insertAdjacentHTML('afterend', '<p class="block block--danger">Failed to fetch challenge from server!</p>');
|
||||
checkbox.parentElement.removeChild(checkbox);
|
||||
});
|
||||
}
|
||||
|
||||
function bindCaptchaLinks() {
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target && event.target.closest('.js-captcha')) {
|
||||
insertCaptcha(event.target.closest('.js-captcha'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { bindCaptchaLinks };
|
197
assets/js/comment.js
Normal file
197
assets/js/comment.js
Normal file
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Comments.
|
||||
*/
|
||||
|
||||
import { $ } from './utils/dom';
|
||||
import { showOwnedComments } from './communications/comment';
|
||||
import { filterNode } from './imagesclientside';
|
||||
import { fetchHtml } from './utils/requests';
|
||||
|
||||
function handleError(response) {
|
||||
|
||||
const errorMessage = '<div>Comment failed to load!</div>';
|
||||
|
||||
if (!response.ok) {
|
||||
return errorMessage;
|
||||
}
|
||||
return response.text();
|
||||
|
||||
}
|
||||
|
||||
function commentPosted(response) {
|
||||
|
||||
const commentEditTab = $('#js-comment-form a[data-click-tab="write"]'),
|
||||
commentEditForm = $('#js-comment-form'),
|
||||
container = document.getElementById('comments'),
|
||||
requestOk = response.ok;
|
||||
|
||||
commentEditTab.click();
|
||||
commentEditForm.reset();
|
||||
|
||||
if (requestOk) {
|
||||
response.text().then(text => displayComments(container, text));
|
||||
}
|
||||
else {
|
||||
window.location.reload();
|
||||
window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function loadParentPost(event) {
|
||||
|
||||
const clickedLink = event.target,
|
||||
// Find the comment containing the link that was clicked
|
||||
fullComment = clickedLink.closest('article.block'),
|
||||
// Look for a potential image and comment ID
|
||||
commentMatches = /(\w+)#comment_(\w+)$/.exec(clickedLink.getAttribute('href'));
|
||||
|
||||
// If the clicked link is already active, just clear the parent comments
|
||||
if (clickedLink.classList.contains('active_reply_link')) {
|
||||
clearParentPost(clickedLink, fullComment);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (commentMatches) {
|
||||
|
||||
// If the regex matched, get the image and comment ID
|
||||
const [ , imageId, commentId ] = commentMatches;
|
||||
|
||||
// Use .html because the default response is JSON
|
||||
fetchHtml(`/images/${imageId}/comments/${commentId}.html`)
|
||||
.then(handleError)
|
||||
.then(data => {
|
||||
clearParentPost(clickedLink, fullComment);
|
||||
insertParentPost(data, clickedLink, fullComment);
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function insertParentPost(data, clickedLink, fullComment) {
|
||||
|
||||
// Add the 'subthread' class to the comment with the clicked link
|
||||
fullComment.classList.add('subthread');
|
||||
|
||||
// Insert parent comment
|
||||
fullComment.insertAdjacentHTML('beforebegin', data);
|
||||
|
||||
// Add class subthread and fetched-comment - use separate add()-methods to support IE11
|
||||
fullComment.previousSibling.classList.add('subthread');
|
||||
fullComment.previousSibling.classList.add('fetched-comment');
|
||||
|
||||
// Execute timeago on the new comment - it was not present when first run
|
||||
window.booru.timeAgo(fullComment.previousSibling.getElementsByTagName('time'));
|
||||
|
||||
// Add class active_reply_link to the clicked link
|
||||
clickedLink.classList.add('active_reply_link');
|
||||
|
||||
// Filter images (if any) in the loaded comment
|
||||
filterNode(fullComment.previousSibling);
|
||||
|
||||
}
|
||||
|
||||
function clearParentPost(clickedLink, fullComment) {
|
||||
|
||||
// Remove any previous siblings with the class fetched-comment
|
||||
while (fullComment.previousSibling && fullComment.previousSibling.classList.contains('fetched-comment')) {
|
||||
fullComment.previousSibling.parentNode.removeChild(fullComment.previousSibling);
|
||||
}
|
||||
|
||||
// Remove class active_reply_link from all links in the comment
|
||||
[].slice.call(fullComment.getElementsByClassName('active_reply_link')).forEach(link => {
|
||||
link.classList.remove('active_reply_link');
|
||||
});
|
||||
|
||||
// If this full comment isn't a fetched comment, remove the subthread class.
|
||||
if (!fullComment.classList.contains('fetched-comment')) {
|
||||
fullComment.classList.remove('subthread');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function displayComments(container, commentsHtml) {
|
||||
|
||||
container.innerHTML = commentsHtml;
|
||||
|
||||
// Execute timeago on comments
|
||||
window.booru.timeAgo(document.getElementsByTagName('time'));
|
||||
|
||||
// Filter images in the comments
|
||||
filterNode(container);
|
||||
|
||||
// Show options on own comments
|
||||
showOwnedComments();
|
||||
|
||||
}
|
||||
|
||||
function loadComments(event) {
|
||||
|
||||
const container = document.getElementById('comments'),
|
||||
hasHref = event.target && event.target.getAttribute('href'),
|
||||
hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/),
|
||||
getURL = hasHref || (hasHash ? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}`
|
||||
: container.dataset.currentUrl);
|
||||
|
||||
fetchHtml(getURL)
|
||||
.then(handleError)
|
||||
.then(data => {
|
||||
|
||||
displayComments(container, data);
|
||||
|
||||
// Make sure the :target CSS selector applies to the inserted content
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=98561
|
||||
if (hasHash) {
|
||||
// eslint-disable-next-line
|
||||
window.location = window.location;
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function setupComments() {
|
||||
const comments = document.getElementById('comments'),
|
||||
hasHash = window.location.hash && window.location.hash.match(/^#comment_([a-f0-9]+)$/),
|
||||
targetOnPage = hasHash ? Boolean($(window.location.hash)) : true;
|
||||
|
||||
// Load comments over AJAX if we are on a page with element #comments
|
||||
if (comments) {
|
||||
if (!comments.dataset.loaded || !targetOnPage) {
|
||||
// There is no event associated with the initial load, so use false
|
||||
loadComments(false);
|
||||
}
|
||||
else {
|
||||
filterNode(comments);
|
||||
showOwnedComments();
|
||||
}
|
||||
}
|
||||
|
||||
// Define clickable elements and the function to execute on click
|
||||
const targets = {
|
||||
'article[id*="comment"] .communication__body__text a[href]': loadParentPost,
|
||||
'#comments .pagination a[href]': loadComments,
|
||||
'#js-refresh-comments': loadComments,
|
||||
};
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
if (event.button === 0) { // Left-click only
|
||||
for (const target in targets) {
|
||||
if (event.target && event.target.closest(target)) {
|
||||
targets[target](event) && event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fetchcomplete', event => {
|
||||
if (event.target.id === 'js-comment-form') commentPosted(event.detail);
|
||||
});
|
||||
}
|
||||
|
||||
export { setupComments };
|
10
assets/js/communications/comment.js
Normal file
10
assets/js/communications/comment.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { $ } from '../utils/dom';
|
||||
|
||||
function showOwnedComments() {
|
||||
const editableComments = $('.js-editable-comments');
|
||||
const editableCommentIds = editableComments && JSON.parse(editableComments.dataset.editable);
|
||||
|
||||
if (editableCommentIds) editableCommentIds.forEach(id => $(`#comment_${id} .owner-options`).classList.remove('hidden'));
|
||||
}
|
||||
|
||||
export { showOwnedComments };
|
10
assets/js/communications/post.js
Normal file
10
assets/js/communications/post.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { $ } from '../utils/dom';
|
||||
|
||||
function showOwnedPosts() {
|
||||
const editablePost = $('.js-editable-posts');
|
||||
const editablePostIds = editablePost && JSON.parse(editablePost.dataset.editable);
|
||||
|
||||
if (editablePostIds) editablePostIds.forEach(id => $(`#post_${id} .owner-options`).classList.remove('hidden'));
|
||||
}
|
||||
|
||||
export { showOwnedPosts };
|
42
assets/js/duplicate_reports.js
Normal file
42
assets/js/duplicate_reports.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Interactive behavior for duplicate reports.
|
||||
*/
|
||||
|
||||
import { $, $$ } from './utils/dom';
|
||||
|
||||
function setupDupeReports() {
|
||||
const [ onion, slider ] = $$('.onion-skin__image, .onion-skin__slider');
|
||||
const swipe = $('.swipe__image');
|
||||
|
||||
if (swipe) setupSwipe(swipe);
|
||||
if (onion) setupOnionSkin(onion, slider);
|
||||
}
|
||||
|
||||
function setupSwipe(swipe) {
|
||||
const [ clip, divider ] = $$('#clip rect, #divider', swipe);
|
||||
const { width } = swipe.viewBox.baseVal;
|
||||
|
||||
function moveDivider({ clientX }) {
|
||||
// Move center to cursor
|
||||
const rect = swipe.getBoundingClientRect();
|
||||
const newX = (clientX - rect.left) * (width / rect.width);
|
||||
|
||||
divider.setAttribute('x', newX);
|
||||
clip.setAttribute('width', newX);
|
||||
}
|
||||
|
||||
swipe.addEventListener('mousemove', moveDivider);
|
||||
}
|
||||
|
||||
function setupOnionSkin(onion, slider) {
|
||||
const target = $('#target', onion);
|
||||
|
||||
function setOpacity() {
|
||||
target.setAttribute('opacity', slider.value);
|
||||
}
|
||||
|
||||
setOpacity();
|
||||
slider.addEventListener('input', setOpacity);
|
||||
}
|
||||
|
||||
export { setupDupeReports };
|
51
assets/js/fingerprint.js
Normal file
51
assets/js/fingerprint.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Fingerprints
|
||||
*/
|
||||
|
||||
// http://stackoverflow.com/a/34842797
|
||||
function hashCode(str) {
|
||||
return str.split('').reduce((prevHash, currVal) =>
|
||||
((prevHash << 5) - prevHash) + currVal.charCodeAt(0), 0) >>> 0;
|
||||
}
|
||||
|
||||
function createFingerprint() {
|
||||
const prints = [
|
||||
navigator.userAgent,
|
||||
navigator.cpuClass,
|
||||
navigator.oscpu,
|
||||
navigator.platform,
|
||||
|
||||
navigator.browserLanguage,
|
||||
navigator.language,
|
||||
navigator.systemLanguage,
|
||||
navigator.userLanguage,
|
||||
|
||||
screen.availLeft,
|
||||
screen.availTop,
|
||||
screen.availWidth,
|
||||
screen.height,
|
||||
screen.width,
|
||||
|
||||
window.devicePixelRatio,
|
||||
new Date().getTimezoneOffset(),
|
||||
];
|
||||
|
||||
return hashCode(prints.join(''));
|
||||
}
|
||||
|
||||
function setFingerprintCookie() {
|
||||
let fingerprint;
|
||||
|
||||
// The prepended 'c' acts as a crude versioning mechanism.
|
||||
try {
|
||||
fingerprint = `c${createFingerprint()}`;
|
||||
}
|
||||
// If fingerprinting fails, use fakeprint "c1836832948" as a last resort.
|
||||
catch (err) {
|
||||
fingerprint = 'c1836832948';
|
||||
}
|
||||
|
||||
document.cookie = `_ses=${fingerprint}; path=/`;
|
||||
}
|
||||
|
||||
export { setFingerprintCookie };
|
45
assets/js/galleries.js
Normal file
45
assets/js/galleries.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Gallery rearrangement.
|
||||
*/
|
||||
|
||||
import { arraysEqual } from './utils/array';
|
||||
import { $, $$ } from './utils/dom';
|
||||
import { initDraggables } from './utils/draggable';
|
||||
import { fetchJson } from './utils/requests';
|
||||
|
||||
export function setupGalleryEditing() {
|
||||
if (!$('.rearrange-button')) return;
|
||||
|
||||
const [ rearrangeEl, saveEl ] = $$('.rearrange-button');
|
||||
const sortableEl = $('#sortable');
|
||||
const containerEl = $('.js-resizable-media-container');
|
||||
|
||||
// Copy array
|
||||
let oldImages = window.booru.galleryImages.slice();
|
||||
let newImages = window.booru.galleryImages.slice();
|
||||
|
||||
initDraggables();
|
||||
|
||||
$$('.media-box', containerEl).forEach(i => i.draggable = true);
|
||||
|
||||
rearrangeEl.addEventListener('click', () => {
|
||||
sortableEl.classList.add('editing');
|
||||
containerEl.classList.add('drag-container');
|
||||
});
|
||||
|
||||
saveEl.addEventListener('click', () => {
|
||||
sortableEl.classList.remove('editing');
|
||||
containerEl.classList.remove('drag-container');
|
||||
|
||||
newImages = $$('.image-container', containerEl).map(i => parseInt(i.dataset.imageId, 10));
|
||||
|
||||
// If nothing changed, don't bother.
|
||||
if (arraysEqual(newImages, oldImages)) return;
|
||||
|
||||
fetchJson('PATCH', saveEl.dataset.reorderPath, {
|
||||
image_ids: newImages,
|
||||
|
||||
// copy the array again so that we have the newly updated set
|
||||
}).then(() => oldImages = newImages.slice());
|
||||
});
|
||||
}
|
160
assets/js/image_expansion.js
Normal file
160
assets/js/image_expansion.js
Normal file
|
@ -0,0 +1,160 @@
|
|||
import { clearEl } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
|
||||
const imageVersions = {
|
||||
// [width, height]
|
||||
small: [320, 240],
|
||||
medium: [800, 600],
|
||||
large: [1280, 1024]
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the appropriate image version for a given width and height
|
||||
* of the viewport and the image dimensions.
|
||||
*/
|
||||
function selectVersion(imageWidth, imageHeight) {
|
||||
let viewWidth = document.documentElement.clientWidth,
|
||||
viewHeight = document.documentElement.clientHeight;
|
||||
|
||||
// load hires if that's what you asked for
|
||||
if (store.get('serve_hidpi')) {
|
||||
viewWidth *= window.devicePixelRatio || 1;
|
||||
viewHeight *= window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
if (viewWidth > 1024 && imageHeight > 1024 && imageHeight > 2.5 * imageWidth) {
|
||||
// Treat as comic-sized dimensions..
|
||||
return 'tall';
|
||||
}
|
||||
|
||||
// Find a version that is larger than the view in one/both axes
|
||||
// .find() is not supported in older browsers, using a loop
|
||||
for (let i = 0, versions = Object.keys(imageVersions); i < versions.length; ++i) {
|
||||
const version = versions[i],
|
||||
dimensions = imageVersions[version],
|
||||
versionWidth = Math.min(imageWidth, dimensions[0]),
|
||||
versionHeight = Math.min(imageHeight, dimensions[1]);
|
||||
if (versionWidth > viewWidth || versionHeight > viewHeight) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
// If the view is larger than any available version, display the original image
|
||||
return 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a target container element, chooses and scales an image
|
||||
* to an appropriate dimension.
|
||||
*/
|
||||
function pickAndResize(elem) {
|
||||
const imageWidth = parseInt(elem.getAttribute('data-width'), 10),
|
||||
imageHeight = parseInt(elem.getAttribute('data-height'), 10),
|
||||
scaled = elem.getAttribute('data-scaled'),
|
||||
uris = JSON.parse(elem.getAttribute('data-uris'));
|
||||
let version = 'full';
|
||||
|
||||
if (scaled === 'true') {
|
||||
version = selectVersion(imageWidth, imageHeight);
|
||||
}
|
||||
|
||||
const uri = uris[version];
|
||||
let imageFormat = /\.(\w+?)$/.exec(uri)[1];
|
||||
|
||||
if (version === 'full' && store.get('serve_webm') && Boolean(uris.mp4)) {
|
||||
imageFormat = 'mp4';
|
||||
}
|
||||
|
||||
// Check if we need to change to avoid flickering
|
||||
if (imageFormat === 'mp4' || imageFormat === 'webm') {
|
||||
for (const sourceEl of elem.querySelectorAll('video source')) {
|
||||
if (sourceEl.src.endsWith(uri) || (imageFormat === 'mp4' && sourceEl.src.endsWith(uris.mp4))) return;
|
||||
}
|
||||
|
||||
// Scrub out the target element.
|
||||
clearEl(elem);
|
||||
}
|
||||
|
||||
if (imageFormat === 'mp4') {
|
||||
elem.classList.add('full-height');
|
||||
elem.insertAdjacentHTML('afterbegin',
|
||||
`<video controls autoplay loop muted playsinline preload="auto" id="image-display"
|
||||
width="${imageWidth}" height="${imageHeight}">
|
||||
<source src="${uris.mp4}" type="video/mp4">
|
||||
<source src="${uris.webm}" type="video/webm">
|
||||
<p class="block block--fixed block--warning">
|
||||
Your browser supports neither MP4/H264 nor
|
||||
WebM/VP8! Please update it to the latest version.
|
||||
</p>
|
||||
</video>`
|
||||
);
|
||||
}
|
||||
else if (imageFormat === 'webm') {
|
||||
elem.insertAdjacentHTML('afterbegin',
|
||||
`<video controls autoplay loop muted playsinline id="image-display">
|
||||
<source src="${uri}" type="video/webm">
|
||||
<source src="${uri.replace(/webm$/, 'mp4')}" type="video/mp4">
|
||||
<p class="block block--fixed block--warning">
|
||||
Your browser supports neither MP4/H264 nor
|
||||
WebM/VP8! Please update it to the latest version.
|
||||
</p>
|
||||
</video>`
|
||||
);
|
||||
const video = elem.querySelector('video');
|
||||
if (scaled === 'true') {
|
||||
video.className = 'image-scaled';
|
||||
}
|
||||
else if (scaled === 'partscaled') {
|
||||
video.className = 'image-partscaled';
|
||||
}
|
||||
}
|
||||
else {
|
||||
let image;
|
||||
if (scaled === 'true') {
|
||||
image = `<picture><img id="image-display" src="${uri}" class="image-scaled"></picture>`;
|
||||
}
|
||||
else if (scaled === 'partscaled') {
|
||||
image = `<picture><img id="image-display" src="${uri}" class="image-partscaled"></picture>`;
|
||||
}
|
||||
else {
|
||||
image = `<picture><img id="image-display" src="${uri}" width="${imageWidth}" height="${imageHeight}"></picture>`;
|
||||
}
|
||||
if (elem.innerHTML === image) return;
|
||||
|
||||
clearEl(elem);
|
||||
elem.insertAdjacentHTML('afterbegin', image);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an event to an image container for updating an image on
|
||||
* click/tap.
|
||||
*/
|
||||
function bindImageForClick(target) {
|
||||
target.addEventListener('click', () => {
|
||||
if (target.getAttribute('data-scaled') === 'true') {
|
||||
target.setAttribute('data-scaled', 'partscaled');
|
||||
}
|
||||
else if (target.getAttribute('data-scaled') === 'partscaled') {
|
||||
target.setAttribute('data-scaled', 'false');
|
||||
}
|
||||
else {
|
||||
target.setAttribute('data-scaled', 'true');
|
||||
}
|
||||
|
||||
pickAndResize(target);
|
||||
});
|
||||
}
|
||||
|
||||
function bindImageTarget() {
|
||||
const target = document.getElementById('image_target');
|
||||
if (target) {
|
||||
pickAndResize(target);
|
||||
bindImageForClick(target);
|
||||
window.addEventListener('resize', () => {
|
||||
pickAndResize(target);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { bindImageTarget };
|
76
assets/js/imagesclientside.js
Normal file
76
assets/js/imagesclientside.js
Normal file
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Client-side image filtering/spoilering.
|
||||
*/
|
||||
|
||||
import { $$, escapeHtml } from './utils/dom';
|
||||
import { setupInteractions } from './interactions';
|
||||
import { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb } from './utils/image';
|
||||
import { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags } from './utils/tag';
|
||||
|
||||
function runFilter(img, test, runCallback) {
|
||||
if (!test || test.length === 0) return false;
|
||||
|
||||
runCallback(img, test);
|
||||
|
||||
// I don't like this.
|
||||
window.booru.imagesWithDownvotingDisabled.push(img.dataset.imageId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
function filterThumbSimple(img, tagsHit) { hideThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `[HIDDEN] ${displayTags(tagsHit)}`); }
|
||||
function spoilerThumbSimple(img, tagsHit) { spoilerThumb(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, displayTags(tagsHit)); }
|
||||
function filterThumbComplex(img) { hideThumb(img, window.booru.hiddenTag, '[HIDDEN] <i>(Complex Filter)</i>'); }
|
||||
function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '<i>(Complex Filter)</i>'); }
|
||||
|
||||
function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `); }
|
||||
function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `); }
|
||||
function filterBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was hidden by a complex tag expression in '); }
|
||||
function spoilerBlockComplex(img) { spoilerBlock(img, window.booru.hiddenTag, 'This image was spoilered by a complex tag expression in '); }
|
||||
|
||||
// ---
|
||||
|
||||
function thumbTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterThumbSimple); }
|
||||
function thumbComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterThumbComplex); }
|
||||
function thumbTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerThumbSimple); }
|
||||
function thumbComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerThumbComplex); }
|
||||
|
||||
function blockTagFilter(tags, img) { return runFilter(img, imageHitsTags(img, tags), filterBlockSimple); }
|
||||
function blockComplexFilter(complex, img) { return runFilter(img, imageHitsComplex(img, complex), filterBlockComplex); }
|
||||
function blockTagSpoiler(tags, img) { return runFilter(img, imageHitsTags(img, tags), spoilerBlockSimple); }
|
||||
function blockComplexSpoiler(complex, img) { return runFilter(img, imageHitsComplex(img, complex), spoilerBlockComplex); }
|
||||
|
||||
// ---
|
||||
|
||||
function filterNode(node = document) {
|
||||
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags();
|
||||
const { hiddenFilter, spoileredFilter } = window.booru;
|
||||
|
||||
// Image thumb boxes with vote and fave buttons on them
|
||||
$$('.image-container', node)
|
||||
.filter(img => !thumbTagFilter(hiddenTags, img))
|
||||
.filter(img => !thumbComplexFilter(hiddenFilter, img))
|
||||
.filter(img => !thumbTagSpoiler(spoileredTags, img))
|
||||
.filter(img => !thumbComplexSpoiler(spoileredFilter, img))
|
||||
.forEach(img => showThumb(img));
|
||||
|
||||
// Individual image pages and images in posts/comments
|
||||
$$('.image-show-container', node)
|
||||
.filter(img => !blockTagFilter(hiddenTags, img))
|
||||
.filter(img => !blockComplexFilter(hiddenFilter, img))
|
||||
.filter(img => !blockTagSpoiler(spoileredTags, img))
|
||||
.filter(img => !blockComplexSpoiler(spoileredFilter, img))
|
||||
.forEach(img => showBlock(img));
|
||||
}
|
||||
|
||||
function initImagesClientside() {
|
||||
window.booru.imagesWithDownvotingDisabled = [];
|
||||
// This fills the imagesWithDownvotingDisabled array
|
||||
filterNode(document);
|
||||
// Once the array is populated, we can initialize interactions
|
||||
setupInteractions();
|
||||
}
|
||||
|
||||
export { initImagesClientside, filterNode };
|
201
assets/js/interactions.js
Normal file
201
assets/js/interactions.js
Normal file
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Interactions.
|
||||
*/
|
||||
|
||||
import { fetchJson } from './utils/requests';
|
||||
|
||||
const endpoints = {
|
||||
fave: `${window.booru.apiEndpoint}interactions/fave`,
|
||||
vote: `${window.booru.apiEndpoint}interactions/vote`,
|
||||
hide: `${window.booru.apiEndpoint}interactions/hide`,
|
||||
};
|
||||
|
||||
const spoilerDownvoteMsg =
|
||||
'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
|
||||
|
||||
/* Quick helper function to less verbosely iterate a QSA */
|
||||
function onImage(id, selector, cb) {
|
||||
[].forEach.call(
|
||||
document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
|
||||
}
|
||||
|
||||
function setScore(imageId, data) {
|
||||
onImage(imageId, '.score',
|
||||
el => el.textContent = data.score);
|
||||
onImage(imageId, '.votes',
|
||||
el => el.textContent = data.votes);
|
||||
onImage(imageId, '.favorites',
|
||||
el => el.textContent = data.favourites);
|
||||
onImage(imageId, '.upvotes',
|
||||
el => el.textContent = data.upvotes);
|
||||
onImage(imageId, '.downvotes',
|
||||
el => el.textContent = data.downvotes);
|
||||
}
|
||||
|
||||
/* These change the visual appearance of interaction links.
|
||||
* Their classes also effect their behavior due to event delegation. */
|
||||
|
||||
function showUpvoted(imageId) {
|
||||
onImage(imageId, '.interaction--upvote',
|
||||
el => el.classList.add('active'));
|
||||
}
|
||||
|
||||
function showDownvoted(imageId) {
|
||||
onImage(imageId, '.interaction--downvote',
|
||||
el => el.classList.add('active'));
|
||||
}
|
||||
|
||||
function showFaved(imageId) {
|
||||
onImage(imageId, '.interaction--fave',
|
||||
el => el.classList.add('active'));
|
||||
}
|
||||
|
||||
function showHidden(imageId) {
|
||||
onImage(imageId, '.interaction--hide',
|
||||
el => el.classList.add('active'));
|
||||
}
|
||||
|
||||
function resetVoted(imageId) {
|
||||
onImage(imageId, '.interaction--upvote',
|
||||
el => el.classList.remove('active'));
|
||||
|
||||
onImage(imageId, '.interaction--downvote',
|
||||
el => el.classList.remove('active'));
|
||||
}
|
||||
|
||||
function resetFaved(imageId) {
|
||||
onImage(imageId, '.interaction--fave',
|
||||
el => el.classList.remove('active'));
|
||||
}
|
||||
|
||||
function resetHidden(imageId) {
|
||||
onImage(imageId, '.interaction--hide',
|
||||
el => el.classList.remove('active'));
|
||||
}
|
||||
|
||||
function interact(type, imageId, value) {
|
||||
return fetchJson('PUT', endpoints[type], {
|
||||
class: 'Image', id: imageId, value
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => setScore(imageId, res));
|
||||
}
|
||||
|
||||
function displayInteractionSet(interactions) {
|
||||
interactions.forEach(i => {
|
||||
switch (i.interaction_type) {
|
||||
case 'faved':
|
||||
showFaved(i.image_id);
|
||||
break;
|
||||
case 'hidden':
|
||||
showHidden(i.image_id);
|
||||
break;
|
||||
default:
|
||||
if (i.value === 'up') showUpvoted(i.image_id);
|
||||
if (i.value === 'down') showDownvoted(i.image_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadInteractions() {
|
||||
|
||||
/* Set up the actual interactions */
|
||||
displayInteractionSet(window.booru.interactions);
|
||||
|
||||
/* Next part is only for image index pages
|
||||
* TODO: find a better way to do this */
|
||||
if (!document.getElementById('imagelist_container')) return;
|
||||
|
||||
/* Users will blind downvote without this */
|
||||
window.booru.imagesWithDownvotingDisabled.forEach(i => {
|
||||
onImage(i, '.interaction--downvote', a => {
|
||||
|
||||
// TODO Use a 'js-' class to target these instead
|
||||
const icon = a.querySelector('i') || a.querySelector('.oc-icon-small');
|
||||
|
||||
icon.setAttribute('title', spoilerDownvoteMsg);
|
||||
a.classList.add('disabled');
|
||||
a.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, true);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const targets = {
|
||||
|
||||
/* Active-state targets first */
|
||||
'.interaction--upvote.active'(imageId) {
|
||||
interact('vote', imageId, 'false')
|
||||
.then(() => resetVoted(imageId));
|
||||
},
|
||||
'.interaction--downvote.active'(imageId) {
|
||||
interact('vote', imageId, 'false')
|
||||
.then(() => resetVoted(imageId));
|
||||
},
|
||||
'.interaction--fave.active'(imageId) {
|
||||
interact('fave', imageId, 'false')
|
||||
.then(() => resetFaved(imageId));
|
||||
},
|
||||
'.interaction--hide.active'(imageId) {
|
||||
interact('hide', imageId, 'false')
|
||||
.then(() => resetHidden(imageId));
|
||||
},
|
||||
|
||||
/* Inactive targets */
|
||||
'.interaction--upvote:not(.active)'(imageId) {
|
||||
interact('vote', imageId, 'up')
|
||||
.then(() => { resetVoted(imageId); showUpvoted(imageId); });
|
||||
},
|
||||
'.interaction--downvote:not(.active)'(imageId) {
|
||||
interact('vote', imageId, 'down')
|
||||
.then(() => { resetVoted(imageId); showDownvoted(imageId); });
|
||||
},
|
||||
'.interaction--fave:not(.active)'(imageId) {
|
||||
interact('fave', imageId, 'true')
|
||||
.then(() => { resetVoted(imageId); showFaved(imageId); showUpvoted(imageId); });
|
||||
},
|
||||
'.interaction--hide:not(.active)'(imageId) {
|
||||
interact('hide', imageId, 'true')
|
||||
.then(() => { showHidden(imageId); });
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
function bindInteractions() {
|
||||
document.addEventListener('click', event => {
|
||||
|
||||
if (event.button === 0) { // Is it a left-click?
|
||||
for (const target in targets) {
|
||||
/* Event delgation doesn't quite grab what we want here. */
|
||||
const link = event.target && event.target.closest(target);
|
||||
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
targets[target](link.dataset.imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function loggedOutInteractions() {
|
||||
[].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'),
|
||||
a => a.setAttribute('href', '/users/sign_in'));
|
||||
}
|
||||
|
||||
function setupInteractions() {
|
||||
if (window.booru.userIsSignedIn) {
|
||||
bindInteractions();
|
||||
loadInteractions();
|
||||
}
|
||||
else {
|
||||
loggedOutInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
export { setupInteractions, displayInteractionSet };
|
871
assets/js/match_query.js
Normal file
871
assets/js/match_query.js
Normal file
|
@ -0,0 +1,871 @@
|
|||
/**
|
||||
* booru.match_query: A port and modification of the search_parser library for
|
||||
* performing client-side filtering.
|
||||
*/
|
||||
|
||||
const tokenList = [
|
||||
['fuzz', /^~(?:\d+(\.\d+)?|\.\d+)/],
|
||||
['boost', /^\^[\-\+]?\d+(\.\d+)?/],
|
||||
['quoted_lit', /^\s*"(?:(?:[^"]|\\")+)"/],
|
||||
['lparen', /^\s*\(\s*/],
|
||||
['rparen', /^\s*\)\s*/],
|
||||
['and_op', /^\s*(?:\&\&|AND)\s+/],
|
||||
['and_op', /^\s*,\s*/],
|
||||
['or_op', /^\s*(?:\|\||OR)\s+/],
|
||||
['not_op', /^\s*NOT(?:\s+|(?=\())/],
|
||||
['not_op', /^\s*[\!\-]\s*/],
|
||||
['space', /^\s+/],
|
||||
['word', /^(?:[^\s,\(\)\^~]|\\[\s,\(\)\^~])+/],
|
||||
['word', /^(?:[^\s,\(\)]|\\[\s,\(\)])+/]
|
||||
],
|
||||
numberFields = ['id', 'width', 'height', 'aspect_ratio',
|
||||
'comment_count', 'score', 'upvotes', 'downvotes',
|
||||
'faves'],
|
||||
dateFields = ['created_at'],
|
||||
literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash',
|
||||
'score', 'uploader', 'source_url', 'description'],
|
||||
termSpaceToImageField = {
|
||||
tags: 'data-image-tag-aliases',
|
||||
score: 'data-score',
|
||||
upvotes: 'data-upvotes',
|
||||
downvotes: 'data-downvotes',
|
||||
uploader: 'data-uploader',
|
||||
// Yeah, I don't think this is reasonably supportable.
|
||||
// faved_by: 'data-faved-by',
|
||||
id: 'data-image-id',
|
||||
width: 'data-width',
|
||||
height: 'data-height',
|
||||
aspect_ratio: 'data-aspect-ratio',
|
||||
comment_count: 'data-comment-count',
|
||||
source_url: 'data-source-url',
|
||||
faves: 'data-faves',
|
||||
sha512_hash: 'data-sha512',
|
||||
orig_sha512_hash: 'data-orig-sha512',
|
||||
created_at: 'data-created-at'
|
||||
};
|
||||
|
||||
|
||||
function SearchTerm(termStr, options) {
|
||||
this.term = termStr.trim();
|
||||
this.parsed = false;
|
||||
}
|
||||
|
||||
SearchTerm.prototype.append = function(substr) {
|
||||
this.term += substr;
|
||||
this.parsed = false;
|
||||
};
|
||||
|
||||
SearchTerm.prototype.parseRangeField = function(field) {
|
||||
let qual;
|
||||
|
||||
if (numberFields.indexOf(field) !== -1) {
|
||||
return [field, 'eq', 'number'];
|
||||
}
|
||||
|
||||
if (dateFields.indexOf(field) !== -1) {
|
||||
return [field, 'eq', 'date'];
|
||||
}
|
||||
|
||||
qual = /^(\w+)\.([lg]te?|eq)$/.exec(field);
|
||||
|
||||
if (qual) {
|
||||
if (numberFields.indexOf(qual[1]) !== -1) {
|
||||
return [qual[1], qual[2], 'number'];
|
||||
}
|
||||
|
||||
if (dateFields.indexOf(qual[1]) !== -1) {
|
||||
return [qual[1], qual[2], 'date'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
SearchTerm.prototype.parseRelativeDate = function(dateVal, qual) {
|
||||
const match = /(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(dateVal);
|
||||
const bounds = {
|
||||
second: 1000,
|
||||
minute: 60000,
|
||||
hour: 3600000,
|
||||
day: 86400000,
|
||||
week: 604800000,
|
||||
month: 2592000000,
|
||||
year: 31536000000
|
||||
};
|
||||
|
||||
if (match) {
|
||||
const amount = parseInt(match[1], 10);
|
||||
const scale = bounds[match[2]];
|
||||
|
||||
const now = new Date().getTime();
|
||||
const bottomDate = new Date(now - (amount * scale));
|
||||
const topDate = new Date(now - ((amount - 1) * scale));
|
||||
|
||||
switch (qual) {
|
||||
case 'lte':
|
||||
return [bottomDate, 'lt'];
|
||||
case 'gte':
|
||||
return [bottomDate, 'gte'];
|
||||
case 'lt':
|
||||
return [bottomDate, 'lt'];
|
||||
case 'gt':
|
||||
return [bottomDate, 'gte'];
|
||||
default:
|
||||
return [[bottomDate, topDate], 'eq'];
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw `Cannot parse date string: ${dateVal}`;
|
||||
}
|
||||
};
|
||||
|
||||
SearchTerm.prototype.parseAbsoluteDate = function(dateVal, qual) {
|
||||
let parseRes = [
|
||||
/^(\d{4})/,
|
||||
/^\-(\d{2})/,
|
||||
/^\-(\d{2})/,
|
||||
/^(?:\s+|T|t)(\d{2})/,
|
||||
/^:(\d{2})/,
|
||||
/^:(\d{2})/
|
||||
],
|
||||
timeZoneOffset = [0, 0],
|
||||
timeData = [0, 0, 1, 0, 0, 0],
|
||||
bottomDate = null,
|
||||
topDate = null,
|
||||
i,
|
||||
match,
|
||||
origDateVal = dateVal;
|
||||
|
||||
match = /([\+\-])(\d{2}):(\d{2})$/.exec(dateVal);
|
||||
if (match) {
|
||||
timeZoneOffset[0] = parseInt(match[2], 10);
|
||||
timeZoneOffset[1] = parseInt(match[3], 10);
|
||||
if (match[1] === '-') {
|
||||
timeZoneOffset[0] *= -1;
|
||||
timeZoneOffset[1] *= -1;
|
||||
}
|
||||
dateVal = dateVal.substr(0, dateVal.length - 6);
|
||||
}
|
||||
else {
|
||||
dateVal = dateVal.replace(/[Zz]$/, '');
|
||||
}
|
||||
|
||||
for (i = 0; i < parseRes.length; i += 1) {
|
||||
if (dateVal.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
match = parseRes[i].exec(dateVal);
|
||||
if (match) {
|
||||
if (i === 1) {
|
||||
timeData[i] = parseInt(match[1], 10) - 1;
|
||||
}
|
||||
else {
|
||||
timeData[i] = parseInt(match[1], 10);
|
||||
}
|
||||
dateVal = dateVal.substr(
|
||||
match[0].length, dateVal.length - match[0].length
|
||||
);
|
||||
}
|
||||
else {
|
||||
throw `Cannot parse date string: ${origDateVal}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (dateVal.length > 0) {
|
||||
throw `Cannot parse date string: ${origDateVal}`;
|
||||
}
|
||||
|
||||
// Apply the user-specified time zone offset. The JS Date constructor
|
||||
// is very flexible here.
|
||||
timeData[3] -= timeZoneOffset[0];
|
||||
timeData[4] -= timeZoneOffset[1];
|
||||
|
||||
switch (qual) {
|
||||
case 'lte':
|
||||
timeData[i - 1] += 1;
|
||||
return [Date.UTC.apply(Date, timeData), 'lt'];
|
||||
case 'gte':
|
||||
return [Date.UTC.apply(Date, timeData), 'gte'];
|
||||
case 'lt':
|
||||
return [Date.UTC.apply(Date, timeData), 'lt'];
|
||||
case 'gt':
|
||||
timeData[i - 1] += 1;
|
||||
return [Date.UTC.apply(Date, timeData), 'gte'];
|
||||
default:
|
||||
bottomDate = Date.UTC.apply(Date, timeData);
|
||||
timeData[i - 1] += 1;
|
||||
topDate = Date.UTC.apply(Date, timeData);
|
||||
return [[bottomDate, topDate], 'eq'];
|
||||
}
|
||||
};
|
||||
|
||||
SearchTerm.prototype.parseDate = function(dateVal, qual) {
|
||||
try {
|
||||
return this.parseAbsoluteDate(dateVal, qual);
|
||||
}
|
||||
catch (_) {
|
||||
return this.parseRelativeDate(dateVal, qual);
|
||||
}
|
||||
};
|
||||
|
||||
SearchTerm.prototype.parse = function(substr) {
|
||||
let matchArr,
|
||||
rangeParsing,
|
||||
candidateTermSpace,
|
||||
termCandidate;
|
||||
|
||||
this.wildcardable = !this.fuzz && !/^"([^"]|\\")+"$/.test(this.term);
|
||||
|
||||
if (!this.wildcardable && !this.fuzz) {
|
||||
this.term = this.term.substr(1, this.term.length - 2);
|
||||
}
|
||||
|
||||
this.term = this._normalizeTerm();
|
||||
|
||||
// N.B.: For the purposes of this parser, boosting effects are ignored.
|
||||
|
||||
// Default.
|
||||
this.termSpace = 'tags';
|
||||
this.termType = 'literal';
|
||||
|
||||
matchArr = this.term.split(':');
|
||||
|
||||
if (matchArr.length > 1) {
|
||||
candidateTermSpace = matchArr[0];
|
||||
termCandidate = matchArr.slice(1).join(':');
|
||||
rangeParsing = this.parseRangeField(candidateTermSpace);
|
||||
|
||||
if (rangeParsing) {
|
||||
this.termSpace = rangeParsing[0];
|
||||
this.termType = rangeParsing[2];
|
||||
|
||||
if (this.termType === 'date') {
|
||||
rangeParsing = this.parseDate(termCandidate, rangeParsing[1]);
|
||||
this.term = rangeParsing[0];
|
||||
this.compare = rangeParsing[1];
|
||||
}
|
||||
else {
|
||||
this.term = parseFloat(termCandidate);
|
||||
this.compare = rangeParsing[1];
|
||||
}
|
||||
|
||||
this.wildcardable = false;
|
||||
}
|
||||
else if (literalFields.indexOf(candidateTermSpace) !== -1) {
|
||||
this.termType = 'literal';
|
||||
this.term = termCandidate;
|
||||
this.termSpace = candidateTermSpace;
|
||||
}
|
||||
else if (candidateTermSpace == 'my') {
|
||||
this.termType = 'my';
|
||||
this.termSpace = termCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.wildcardable) {
|
||||
// Transforms wildcard match into regular expression.
|
||||
// A custom NFA with caching may be more sophisticated but not
|
||||
// likely to be faster.
|
||||
this.term = new RegExp(
|
||||
`^${
|
||||
this.term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
|
||||
.replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
|
||||
.replace(/^(?:\\\\)*\*/g, '.*')
|
||||
.replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
|
||||
.replace(/^(?:\\\\)*\?/g, '.?')
|
||||
}$`, 'i'
|
||||
);
|
||||
}
|
||||
|
||||
// Update parse status flag to indicate the new properties are ready.
|
||||
this.parsed = true;
|
||||
};
|
||||
|
||||
SearchTerm.prototype._normalizeTerm = function() {
|
||||
if (!this.wildcardable) {
|
||||
return this.term.replace('\"', '"');
|
||||
}
|
||||
return this.term.replace(/\\([^\*\?])/g, '$1');
|
||||
};
|
||||
|
||||
SearchTerm.prototype.fuzzyMatch = function(targetStr) {
|
||||
let targetDistance,
|
||||
i,
|
||||
j,
|
||||
// Work vectors, representing the last three populated
|
||||
// rows of the dynamic programming matrix of the iterative
|
||||
// optimal string alignment calculation.
|
||||
v0 = [],
|
||||
v1 = [],
|
||||
v2 = [],
|
||||
temp;
|
||||
|
||||
if (this.fuzz < 1.0) {
|
||||
targetDistance = targetStr.length * (1.0 - this.fuzz);
|
||||
}
|
||||
else {
|
||||
targetDistance = this.fuzz;
|
||||
}
|
||||
|
||||
targetStr = targetStr.toLowerCase();
|
||||
|
||||
for (i = 0; i <= targetStr.length; i += 1) {
|
||||
v1.push(i);
|
||||
}
|
||||
|
||||
for (i = 0; i < this.term.length; i += 1) {
|
||||
v2[0] = i;
|
||||
for (j = 0; j < targetStr.length; j += 1) {
|
||||
const cost = this.term[i] === targetStr[j] ? 0 : 1;
|
||||
v2[j + 1] = Math.min(
|
||||
// Deletion.
|
||||
v1[j + 1] + 1,
|
||||
// Insertion.
|
||||
v2[j] + 1,
|
||||
// Substitution or No Change.
|
||||
v1[j] + cost
|
||||
);
|
||||
if (i > 1 && j > 1 && this.term[i] === targetStr[j - 1] &&
|
||||
targetStr[i - 1] === targetStr[j]) {
|
||||
v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost);
|
||||
}
|
||||
}
|
||||
// Rotate dem vec pointers bra.
|
||||
temp = v0;
|
||||
v0 = v1;
|
||||
v1 = v2;
|
||||
v2 = temp;
|
||||
}
|
||||
|
||||
return v1[targetStr.length] <= targetDistance;
|
||||
};
|
||||
|
||||
SearchTerm.prototype.exactMatch = function(targetStr) {
|
||||
return this.term.toLowerCase() === targetStr.toLowerCase();
|
||||
};
|
||||
|
||||
SearchTerm.prototype.wildcardMatch = function(targetStr) {
|
||||
return this.term.test(targetStr);
|
||||
};
|
||||
|
||||
SearchTerm.prototype.interactionMatch = function(imageID, type, interaction, interactions) {
|
||||
let ret = false;
|
||||
|
||||
interactions.forEach(v => {
|
||||
if (v.image_id == imageID && v.interaction_type == type && (interaction == null || v.value == interaction)) {
|
||||
ret = true;
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
SearchTerm.prototype.match = function(target) {
|
||||
let ret = false,
|
||||
ohffs = this,
|
||||
compFunc,
|
||||
numbuh,
|
||||
date;
|
||||
|
||||
if (!this.parsed) {
|
||||
this.parse();
|
||||
}
|
||||
|
||||
if (this.termType === 'literal') {
|
||||
// Literal matching.
|
||||
if (this.fuzz) {
|
||||
compFunc = this.fuzzyMatch;
|
||||
}
|
||||
else if (this.wildcardable) {
|
||||
compFunc = this.wildcardMatch;
|
||||
}
|
||||
else {
|
||||
compFunc = this.exactMatch;
|
||||
}
|
||||
|
||||
if (this.termSpace === 'tags') {
|
||||
target.getAttribute('data-image-tag-aliases').split(', ').every(
|
||||
str => {
|
||||
if (compFunc.call(ohffs, str)) {
|
||||
ret = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
else {
|
||||
ret = compFunc.call(
|
||||
this, target.getAttribute(termSpaceToImageField[this.termSpace])
|
||||
);
|
||||
}
|
||||
}
|
||||
else if (this.termType === 'my' && window.booru.interactions.length > 0) {
|
||||
// Should work with most my:conditions except watched.
|
||||
switch (this.termSpace) {
|
||||
case 'faves':
|
||||
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'faved', null, window.booru.interactions);
|
||||
|
||||
break;
|
||||
case 'upvotes':
|
||||
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'up', window.booru.interactions);
|
||||
|
||||
break;
|
||||
case 'downvotes':
|
||||
ret = this.interactionMatch(target.getAttribute('data-image-id'), 'voted', 'down', window.booru.interactions);
|
||||
|
||||
break;
|
||||
default:
|
||||
ret = false; // Other my: interactions aren't supported, return false to prevent them from triggering spoiler.
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (this.termType === 'date') {
|
||||
// Date matching.
|
||||
date = (new Date(
|
||||
target.getAttribute(termSpaceToImageField[this.termSpace])
|
||||
)).getTime();
|
||||
|
||||
switch (this.compare) {
|
||||
// The open-left, closed-right date range specified by the
|
||||
// date/time format limits the types of comparisons that are
|
||||
// done compared to numeric ranges.
|
||||
case 'lt':
|
||||
ret = this.term > date;
|
||||
break;
|
||||
case 'gte':
|
||||
ret = this.term <= date;
|
||||
break;
|
||||
default:
|
||||
ret = this.term[0] <= date && this.term[1] > date;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Range matching.
|
||||
numbuh = parseFloat(
|
||||
target.getAttribute(termSpaceToImageField[this.termSpace])
|
||||
);
|
||||
|
||||
if (isNaN(this.term)) {
|
||||
ret = false;
|
||||
}
|
||||
else if (this.fuzz) {
|
||||
ret = this.term <= numbuh + this.fuzz &&
|
||||
this.term + this.fuzz >= numbuh;
|
||||
}
|
||||
else {
|
||||
switch (this.compare) {
|
||||
case 'lt':
|
||||
ret = this.term > numbuh;
|
||||
break;
|
||||
case 'gt':
|
||||
ret = this.term < numbuh;
|
||||
break;
|
||||
case 'lte':
|
||||
ret = this.term >= numbuh;
|
||||
break;
|
||||
case 'gte':
|
||||
ret = this.term <= numbuh;
|
||||
break;
|
||||
default:
|
||||
ret = this.term === numbuh;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
function generateLexArray(searchStr, options) {
|
||||
let opQueue = [],
|
||||
searchTerm = null,
|
||||
boost = null,
|
||||
fuzz = null,
|
||||
lparenCtr = 0,
|
||||
negate = false,
|
||||
groupNegate = false,
|
||||
tokenStack = [],
|
||||
boostFuzzStr = '';
|
||||
|
||||
while (searchStr.length > 0) {
|
||||
tokenList.every(tokenArr => {
|
||||
let tokenName = tokenArr[0],
|
||||
tokenRE = tokenArr[1],
|
||||
match = tokenRE.exec(searchStr),
|
||||
balanced, op;
|
||||
|
||||
if (match) {
|
||||
match = match[0];
|
||||
|
||||
if (Boolean(searchTerm) && (
|
||||
['and_op', 'or_op'].indexOf(tokenName) !== -1 ||
|
||||
tokenName === 'rparen' && lparenCtr === 0)) {
|
||||
// Set options.
|
||||
searchTerm.boost = boost;
|
||||
searchTerm.fuzz = fuzz;
|
||||
// Push to stack.
|
||||
tokenStack.push(searchTerm);
|
||||
// Reset term and options data.
|
||||
searchTerm = fuzz = boost = null;
|
||||
boostFuzzStr = '';
|
||||
lparenCtr = 0;
|
||||
|
||||
if (negate) {
|
||||
tokenStack.push('not_op');
|
||||
negate = false;
|
||||
}
|
||||
}
|
||||
|
||||
switch (tokenName) {
|
||||
case 'and_op':
|
||||
while (opQueue[0] === 'and_op') {
|
||||
tokenStack.push(opQueue.shift());
|
||||
}
|
||||
opQueue.unshift('and_op');
|
||||
break;
|
||||
case 'or_op':
|
||||
while (opQueue[0] === 'and_op' || opQueue[0] === 'or_op') {
|
||||
tokenStack.push(opQueue.shift());
|
||||
}
|
||||
opQueue.unshift('or_op');
|
||||
break;
|
||||
case 'not_op':
|
||||
if (searchTerm) {
|
||||
// We're already inside a search term, so it does
|
||||
// not apply, obv.
|
||||
searchTerm.append(match);
|
||||
}
|
||||
else {
|
||||
negate = !negate;
|
||||
}
|
||||
break;
|
||||
case 'lparen':
|
||||
if (searchTerm) {
|
||||
// If we are inside the search term, do not error
|
||||
// out just yet; instead, consider it as part of
|
||||
// the search term, as a user convenience.
|
||||
searchTerm.append(match);
|
||||
lparenCtr += 1;
|
||||
}
|
||||
else {
|
||||
opQueue.unshift('lparen');
|
||||
groupNegate = negate;
|
||||
negate = false;
|
||||
}
|
||||
break;
|
||||
case 'rparen':
|
||||
if (lparenCtr > 0) {
|
||||
searchTerm.append(match);
|
||||
lparenCtr -= 1;
|
||||
}
|
||||
else {
|
||||
balanced = false;
|
||||
while (opQueue.length) {
|
||||
op = opQueue.shift();
|
||||
if (op === 'lparen') {
|
||||
balanced = true;
|
||||
break;
|
||||
}
|
||||
tokenStack.push(op);
|
||||
}
|
||||
}
|
||||
if (groupNegate) {
|
||||
tokenStack.push('not_op');
|
||||
groupNegate = false;
|
||||
}
|
||||
break;
|
||||
case 'fuzz':
|
||||
if (searchTerm) {
|
||||
// For this and boost operations, we store the
|
||||
// current match so far to a temporary string in
|
||||
// case this is actually inside the term.
|
||||
fuzz = parseFloat(match.substr(1));
|
||||
boostFuzzStr += match;
|
||||
}
|
||||
else {
|
||||
searchTerm = new SearchTerm(match, options);
|
||||
}
|
||||
break;
|
||||
case 'boost':
|
||||
if (searchTerm) {
|
||||
boost = match.substr(1);
|
||||
boostFuzzStr += match;
|
||||
}
|
||||
else {
|
||||
searchTerm = new SearchTerm(match, options);
|
||||
}
|
||||
break;
|
||||
case 'quoted_lit':
|
||||
if (searchTerm) {
|
||||
searchTerm.append(match);
|
||||
}
|
||||
else {
|
||||
searchTerm = new SearchTerm(match, options);
|
||||
}
|
||||
break;
|
||||
case 'word':
|
||||
if (searchTerm) {
|
||||
if (fuzz || boost) {
|
||||
boost = fuzz = null;
|
||||
searchTerm.append(boostFuzzStr);
|
||||
boostFuzzStr = '';
|
||||
}
|
||||
searchTerm.append(match);
|
||||
}
|
||||
else {
|
||||
searchTerm = new SearchTerm(match, options);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Append extra spaces within search terms.
|
||||
if (searchTerm) {
|
||||
searchTerm.append(match);
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate string and restart the token tests.
|
||||
searchStr = searchStr.substr(
|
||||
match.length, searchStr.length - match.length
|
||||
);
|
||||
|
||||
// Break since we have found a match.
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Append final tokens to the stack, starting with the search term.
|
||||
if (searchTerm) {
|
||||
searchTerm.boost = boost;
|
||||
searchTerm.fuzz = fuzz;
|
||||
tokenStack.push(searchTerm);
|
||||
}
|
||||
if (negate) {
|
||||
tokenStack.push('not_op');
|
||||
}
|
||||
|
||||
if (opQueue.indexOf('rparen') !== -1 ||
|
||||
opQueue.indexOf('lparen') !== -1) {
|
||||
throw 'Mismatched parentheses.';
|
||||
}
|
||||
|
||||
// Memory-efficient concatenation of remaining operators queue to the
|
||||
// token stack.
|
||||
tokenStack.push.apply(tokenStack, opQueue);
|
||||
|
||||
return tokenStack;
|
||||
}
|
||||
|
||||
function parseTokens(lexicalArray) {
|
||||
let operandStack = [],
|
||||
negate, op1, op2, parsed;
|
||||
lexicalArray.forEach((token, i) => {
|
||||
if (token !== 'not_op') {
|
||||
negate = lexicalArray[i + 1] === 'not_op';
|
||||
|
||||
if (typeof token === 'string') {
|
||||
op2 = operandStack.pop();
|
||||
op1 = operandStack.pop();
|
||||
|
||||
if (typeof op1 === 'undefined' || typeof op2 === 'undefined') {
|
||||
throw 'Missing operand.';
|
||||
}
|
||||
|
||||
operandStack.push(new SearchAST(token, negate, op1, op2));
|
||||
}
|
||||
else {
|
||||
if (negate) {
|
||||
operandStack.push(new SearchAST(null, true, token));
|
||||
}
|
||||
else {
|
||||
operandStack.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (operandStack.length > 1) {
|
||||
throw 'Missing operator.';
|
||||
}
|
||||
|
||||
op1 = operandStack.pop();
|
||||
|
||||
if (typeof op1 === 'undefined') {
|
||||
return new SearchAST();
|
||||
}
|
||||
|
||||
if (isTerminal(op1)) {
|
||||
return new SearchAST(null, false, op1);
|
||||
}
|
||||
|
||||
return op1;
|
||||
}
|
||||
|
||||
function parseSearch(searchStr, options) {
|
||||
return parseTokens(generateLexArray(searchStr, options));
|
||||
}
|
||||
|
||||
function isTerminal(operand) {
|
||||
// Whether operand is a terminal SearchTerm.
|
||||
return typeof operand.term !== 'undefined';
|
||||
}
|
||||
|
||||
function SearchAST(op, negate, leftOperand, rightOperand) {
|
||||
this.negate = Boolean(negate);
|
||||
this.leftOperand = leftOperand || null;
|
||||
this.op = op || null;
|
||||
this.rightOperand = rightOperand || null;
|
||||
}
|
||||
|
||||
function combineOperands(ast1, ast2, parentAST) {
|
||||
if (parentAST.op === 'and_op') {
|
||||
ast1 = ast1 && ast2;
|
||||
}
|
||||
else {
|
||||
ast1 = ast1 || ast2;
|
||||
}
|
||||
|
||||
if (parentAST.negate) {
|
||||
return !ast1;
|
||||
}
|
||||
|
||||
return ast1;
|
||||
}
|
||||
|
||||
// Evaluation of the AST in regard to a target image
|
||||
SearchAST.prototype.hitsImage = function(image) {
|
||||
let treeStack = [],
|
||||
// Left side node.
|
||||
ast1 = this,
|
||||
// Right side node.
|
||||
ast2,
|
||||
// Parent node of the current subtree.
|
||||
parentAST;
|
||||
|
||||
// Build the initial tree node traversal stack, of the "far left" side.
|
||||
// The general idea is to accumulate from the bottom and make stacks
|
||||
// of right-hand subtrees that themselves accumulate upward. The left
|
||||
// side node, ast1, will always be a Boolean representing the left-side
|
||||
// evaluated value, up to the current subtree (parentAST).
|
||||
while (!isTerminal(ast1)) {
|
||||
treeStack.push(ast1);
|
||||
ast1 = ast1.leftOperand;
|
||||
|
||||
if (!ast1) {
|
||||
// Empty tree.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ast1 = ast1.match(image);
|
||||
treeStack.push(ast1);
|
||||
|
||||
while (treeStack.length > 0) {
|
||||
parentAST = treeStack.pop();
|
||||
|
||||
if (parentAST === null) {
|
||||
// We are at the end of a virtual stack for a right node
|
||||
// subtree. We switch the result of this stack from left
|
||||
// (ast1) to right (ast2), pop the original left node,
|
||||
// and finally pop the parent subtree itself. See near the
|
||||
// end of this function to view how this is populated.
|
||||
ast2 = ast1;
|
||||
ast1 = treeStack.pop();
|
||||
parentAST = treeStack.pop();
|
||||
}
|
||||
else {
|
||||
// First, check to see if we can do a short-circuit
|
||||
// evaluation to skip evaluating the right side entirely.
|
||||
if (!ast1 && parentAST.op === 'and_op') {
|
||||
ast1 = parentAST.negate;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ast1 && parentAST.op === 'or_op') {
|
||||
ast1 = !parentAST.negate;
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we are not at the end of a stack, grab the right
|
||||
// node. The left node (ast1) is currently a terminal Boolean.
|
||||
ast2 = parentAST.rightOperand;
|
||||
}
|
||||
|
||||
if (typeof ast2 === 'boolean') {
|
||||
ast1 = combineOperands(ast1, ast2, parentAST);
|
||||
}
|
||||
else if (!ast2) {
|
||||
// A subtree with a single node. This is generally the case
|
||||
// for negated tokens.
|
||||
if (parentAST.negate) {
|
||||
ast1 = !ast1;
|
||||
}
|
||||
}
|
||||
else if (isTerminal(ast2)) {
|
||||
// We are finally at a leaf and can evaluate.
|
||||
ast2 = ast2.match(image);
|
||||
ast1 = combineOperands(ast1, ast2, parentAST);
|
||||
}
|
||||
else {
|
||||
// We are at a node whose right side is a new subtree.
|
||||
// We will build a new "virtual" stack, but instead of
|
||||
// building a new Array, we can insert a null object as a
|
||||
// marker.
|
||||
treeStack.push(parentAST, ast1, null);
|
||||
|
||||
do {
|
||||
treeStack.push(ast2);
|
||||
ast2 = ast2.leftOperand;
|
||||
} while (!isTerminal(ast2));
|
||||
|
||||
ast1 = ast2.match(image);
|
||||
}
|
||||
}
|
||||
|
||||
return ast1;
|
||||
};
|
||||
|
||||
SearchAST.prototype.dumpTree = function() {
|
||||
// Dumps to string a simple diagram of the syntax tree structure
|
||||
// (starting with this object as the root) for debugging purposes.
|
||||
let retStrArr = [],
|
||||
treeQueue = [['', this]],
|
||||
treeArr,
|
||||
prefix,
|
||||
tree;
|
||||
|
||||
while (treeQueue.length > 0) {
|
||||
treeArr = treeQueue.shift();
|
||||
prefix = treeArr[0];
|
||||
tree = treeArr[1];
|
||||
|
||||
if (isTerminal(tree)) {
|
||||
retStrArr.push(`${prefix}-> ${tree.term}`);
|
||||
}
|
||||
else {
|
||||
if (tree.negate) {
|
||||
retStrArr.push(`${prefix}+ NOT_OP`);
|
||||
prefix += '\t';
|
||||
}
|
||||
if (tree.op) {
|
||||
retStrArr.push(`${prefix}+ ${tree.op.toUpperCase()}`);
|
||||
prefix += '\t';
|
||||
treeQueue.unshift([prefix, tree.rightOperand]);
|
||||
treeQueue.unshift([prefix, tree.leftOperand]);
|
||||
}
|
||||
else {
|
||||
treeQueue.unshift([prefix, tree.leftOperand]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return retStrArr.join('\n');
|
||||
};
|
||||
|
||||
export default parseSearch;
|
84
assets/js/misc.js
Normal file
84
assets/js/misc.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Misc
|
||||
*/
|
||||
|
||||
import store from './utils/store';
|
||||
import { $ } from './utils/dom';
|
||||
|
||||
let touchMoved = false;
|
||||
|
||||
function formResult({target, detail}) {
|
||||
|
||||
const elements = {
|
||||
'#description-form': '.image-description',
|
||||
'#uploader-form': '.image_uploader',
|
||||
'#source-form': '#image-source'
|
||||
};
|
||||
|
||||
function showResult(resultEl, formEl, response) {
|
||||
resultEl.innerHTML = response;
|
||||
resultEl.classList.remove('hidden');
|
||||
formEl.classList.add('hidden');
|
||||
formEl.querySelector('input[type="submit"]').disabled = false;
|
||||
}
|
||||
|
||||
for (const element in elements) {
|
||||
if (target.matches(element)) detail.text().then(text => showResult($(elements[element]), target, text));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function revealSpoiler(event) {
|
||||
|
||||
const { target } = event;
|
||||
const spoiler = target.closest('.spoiler');
|
||||
let imgspoiler = target.closest('.spoiler .imgspoiler, .spoiler-revealed .imgspoiler');
|
||||
const showContainer = target.closest('.image-show-container');
|
||||
|
||||
// Prevent reveal if touchend came after touchmove event
|
||||
if (touchMoved) {
|
||||
touchMoved = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (spoiler) {
|
||||
if (showContainer) {
|
||||
const imageShow = showContainer.querySelector('.image-show');
|
||||
if (!imageShow.classList.contains('hidden') && imageShow.classList.contains('spoiler-pending')) {
|
||||
imageShow.classList.remove('spoiler-pending');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spoiler.classList.remove('spoiler');
|
||||
spoiler.classList.add('spoiler-revealed');
|
||||
// Prevent click-through to links on mobile platforms
|
||||
if (event.type === 'touchend') event.preventDefault();
|
||||
|
||||
if (!imgspoiler) {
|
||||
imgspoiler = spoiler.querySelector('.imgspoiler');
|
||||
}
|
||||
}
|
||||
|
||||
if (imgspoiler) {
|
||||
imgspoiler.classList.remove('imgspoiler');
|
||||
imgspoiler.classList.add('imgspoiler-revealed');
|
||||
if (event.type === 'touchend' && !event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function setupEvents() {
|
||||
const extrameta = $('#extrameta');
|
||||
|
||||
if (store.get('hide_uploader') && extrameta) extrameta.classList.add('hidden');
|
||||
|
||||
document.addEventListener('fetchcomplete', formResult);
|
||||
document.addEventListener('click', revealSpoiler);
|
||||
document.addEventListener('touchend', revealSpoiler);
|
||||
document.addEventListener('touchmove', () => touchMoved = true);
|
||||
}
|
||||
|
||||
export { setupEvents };
|
77
assets/js/notifications.js
Normal file
77
assets/js/notifications.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Notifications
|
||||
*/
|
||||
|
||||
import { fetchJson, handleError } from './utils/requests';
|
||||
import { $, $$, hideEl, toggleEl } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
|
||||
const NOTIFICATION_INTERVAL = 600000,
|
||||
NOTIFICATION_EXPIRES = 300000;
|
||||
|
||||
function makeRequest(verb, action, body) {
|
||||
return fetchJson(verb, `${window.booru.apiEndpoint}notifications/${action}.json`, body).then(handleError);
|
||||
}
|
||||
|
||||
|
||||
function toggleSubscription(data) {
|
||||
const { subscriptionId, subscriptionType } = data.el.dataset;
|
||||
const subscriptionElements = $$(`.js-notification-${subscriptionType + subscriptionId}`);
|
||||
|
||||
makeRequest('PUT', data.value, { id: subscriptionId, actor_class: subscriptionType }) // eslint-disable-line camelcase
|
||||
.then(() => toggleEl(subscriptionElements))
|
||||
.catch(() => data.el.textContent = 'Error!');
|
||||
}
|
||||
|
||||
|
||||
function markRead(data) {
|
||||
const notificationId = data.value;
|
||||
const notification = $(`.js-notification-id-${notificationId}`);
|
||||
|
||||
makeRequest('PUT', 'mark_read', { id: notificationId })
|
||||
.then(() => hideEl(notification))
|
||||
.catch(() => data.el.textContent = 'Error!');
|
||||
}
|
||||
|
||||
|
||||
function getNewNotifications() {
|
||||
if (document.hidden || !store.hasExpired('notificationCount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
makeRequest('GET', 'unread').then(response => response.json()).then(({ data }) => {
|
||||
updateNotificationTicker(data.length);
|
||||
storeNotificationCount(data.length);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function updateNotificationTicker(notificationCount) {
|
||||
const ticker = $('.js-notification-ticker');
|
||||
const parsedNotificationCount = Number(notificationCount);
|
||||
|
||||
ticker.dataset.notificationCount = parsedNotificationCount;
|
||||
ticker.textContent = parsedNotificationCount;
|
||||
}
|
||||
|
||||
|
||||
function storeNotificationCount(notificationCount) {
|
||||
// The current number of notifications are stored along with the time when the data expires
|
||||
store.setWithExpireTime('notificationCount', notificationCount, NOTIFICATION_EXPIRES);
|
||||
}
|
||||
|
||||
|
||||
function setupNotifications() {
|
||||
if (!window.booru.userIsSignedIn) return;
|
||||
|
||||
// Fetch notifications from the server at a regular interval
|
||||
setInterval(getNewNotifications, NOTIFICATION_INTERVAL);
|
||||
|
||||
// Update the current number of notifications based on the latest page load
|
||||
storeNotificationCount($('.js-notification-ticker').dataset.notificationCount);
|
||||
|
||||
// Update ticker when the stored value changes - this will occur in all open tabs
|
||||
store.watch('notificationCount', updateNotificationTicker);
|
||||
}
|
||||
|
||||
export { setupNotifications, toggleSubscription, markRead };
|
36
assets/js/poll.js
Normal file
36
assets/js/poll.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { $, $$, clearEl, removeEl, insertBefore } from './utils/dom';
|
||||
|
||||
function pollOptionCreator() {
|
||||
const addPollOptionButton = $('.js-poll-add-option');
|
||||
|
||||
if (!addPollOptionButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = addPollOptionButton.closest('form');
|
||||
const maxOptionCount = parseInt($('.js-max-option-count', form).innerHTML, 10);
|
||||
addPollOptionButton.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
|
||||
let existingOptionCount = $$('.js-poll-option', form).length;
|
||||
if (existingOptionCount < maxOptionCount) {
|
||||
// The element right before the add button will always be the last field, make a copy
|
||||
const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true);
|
||||
// Clear its value and increment the N in "Option N" in the placeholder attribute
|
||||
clearEl($$('.js-option-id', prevFieldCopy));
|
||||
const input = $('.js-option-label', prevFieldCopy);
|
||||
input.value = '';
|
||||
input.setAttribute('placeholder', input.getAttribute('placeholder').replace(/\d+$/, m => parseInt(m, 10) + 1));
|
||||
// Insert copy before the button
|
||||
insertBefore(addPollOptionButton, prevFieldCopy);
|
||||
existingOptionCount++;
|
||||
}
|
||||
|
||||
// Remove the button if we reached the max number of options
|
||||
if (existingOptionCount >= maxOptionCount) {
|
||||
removeEl(addPollOptionButton);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { pollOptionCreator };
|
88
assets/js/preview.js
Normal file
88
assets/js/preview.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* Textile previews (posts, comments, messages)
|
||||
*/
|
||||
|
||||
import { fetchJson } from './utils/requests';
|
||||
import { filterNode } from './imagesclientside';
|
||||
|
||||
function handleError(response) {
|
||||
const errorMessage = '<div>Preview failed to load!</div>';
|
||||
|
||||
if (!response.ok) {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function commentReply(user, url, textarea, quote) {
|
||||
const text = `"@${user}":${url}`;
|
||||
let newval = textarea.value;
|
||||
|
||||
if (newval && /\n$/.test(newval)) newval += '\n';
|
||||
newval += `${text}\n`;
|
||||
|
||||
if (quote) {
|
||||
newval += `[bq="${user.replace('"', '\'')}"] ${quote} [/bq]\n`;
|
||||
}
|
||||
|
||||
textarea.value = newval;
|
||||
textarea.selectionStart = textarea.selectionEnd = newval.length;
|
||||
|
||||
const writeTabToggle = document.querySelector('a[data-click-tab="write"]:not(.selected)');
|
||||
if (writeTabToggle) writeTabToggle.click();
|
||||
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function getPreview(body, anonymous, previewTab, isImage = false) {
|
||||
let path = '/posts/preview';
|
||||
|
||||
if (isImage) path = '/images/preview';
|
||||
|
||||
fetchJson('POST', path, { body, anonymous })
|
||||
.then(handleError)
|
||||
.then(data => {
|
||||
previewTab.innerHTML = data;
|
||||
filterNode(previewTab);
|
||||
});
|
||||
}
|
||||
|
||||
function setupPreviews() {
|
||||
let textarea = document.querySelector('.js-preview-input');
|
||||
let imageDesc = false;
|
||||
|
||||
if (!textarea) {
|
||||
textarea = document.querySelector('.js-preview-description');
|
||||
imageDesc = true;
|
||||
}
|
||||
|
||||
const previewButton = document.querySelector('a[data-click-tab="preview"]');
|
||||
const previewTab = document.querySelector('.block__tab[data-tab="preview"]');
|
||||
const previewAnon = document.querySelector('.preview-anonymous') || false;
|
||||
|
||||
if (!textarea || !previewButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewButton.addEventListener('click', () => {
|
||||
if (previewTab.previewedText === textarea.value) return;
|
||||
previewTab.previewedText = textarea.value;
|
||||
|
||||
getPreview(textarea.value, Boolean(previewAnon.checked), previewTab, imageDesc);
|
||||
});
|
||||
|
||||
previewAnon && previewAnon.addEventListener('click', () => {
|
||||
getPreview(textarea.value, Boolean(previewAnon.checked), previewTab, imageDesc);
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
if (event.target && event.target.closest('.post-reply')) {
|
||||
const link = event.target.closest('.post-reply');
|
||||
commentReply(link.dataset.author, link.getAttribute('href'), textarea, link.dataset.post);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { setupPreviews };
|
113
assets/js/quick-tag.js
Normal file
113
assets/js/quick-tag.js
Normal file
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Quick Tag
|
||||
*/
|
||||
|
||||
import store from './utils/store';
|
||||
import { $, $$, toggleEl, onLeftClick } from './utils/dom';
|
||||
import { fetchJson, handleError } from './utils/requests';
|
||||
|
||||
const imageQueueStorage = 'quickTagQueue';
|
||||
const currentTagStorage = 'quickTagName';
|
||||
|
||||
function currentQueue() { return store.get(imageQueueStorage) || []; }
|
||||
|
||||
function currentTags() { return store.get(currentTagStorage) || ''; }
|
||||
|
||||
function getTagButton() { return $('.js-quick-tag'); }
|
||||
|
||||
function setTagButton(text) { $('.js-quick-tag--submit span').textContent = text; }
|
||||
|
||||
function toggleActiveState() {
|
||||
|
||||
toggleEl($('.js-quick-tag'),
|
||||
$('.js-quick-tag--abort'),
|
||||
$('.js-quick-tag--submit'));
|
||||
|
||||
setTagButton(`Submit (${currentTags()})`);
|
||||
|
||||
$$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected'));
|
||||
$$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected'));
|
||||
currentQueue().forEach(id => $$(`.media-box__header[data-image-id="${id}"]`).forEach(el => el.classList.add('media-box__header--selected')));
|
||||
|
||||
}
|
||||
|
||||
function activate() {
|
||||
|
||||
store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:'));
|
||||
|
||||
if (currentTags()) toggleActiveState();
|
||||
|
||||
}
|
||||
|
||||
function reset() {
|
||||
|
||||
store.remove(currentTagStorage);
|
||||
store.remove(imageQueueStorage);
|
||||
|
||||
toggleActiveState();
|
||||
|
||||
}
|
||||
|
||||
function submit() {
|
||||
|
||||
setTagButton(`Wait... (${currentTags()})`);
|
||||
|
||||
fetchJson('PUT', '/admin/batch/tags', {
|
||||
tags: currentTags(),
|
||||
image_ids: currentQueue(),
|
||||
})
|
||||
.then(handleError)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`);
|
||||
|
||||
reset();
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function modifyImageQueue(mediaBox) {
|
||||
|
||||
if (currentTags()) {
|
||||
const imageId = mediaBox.dataset.imageId,
|
||||
queue = currentQueue(),
|
||||
isSelected = queue.includes(imageId);
|
||||
|
||||
isSelected ? queue.splice(queue.indexOf(imageId), 1)
|
||||
: queue.push(imageId);
|
||||
|
||||
$$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => el.classList.toggle('media-box__header--selected'));
|
||||
|
||||
store.set(imageQueueStorage, queue);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function clickHandler(event) {
|
||||
|
||||
const targets = {
|
||||
'.js-quick-tag': activate,
|
||||
'.js-quick-tag--abort': reset,
|
||||
'.js-quick-tag--submit': submit,
|
||||
'.media-box': modifyImageQueue,
|
||||
};
|
||||
|
||||
for (const target in targets) {
|
||||
if (event.target && event.target.closest(target)) {
|
||||
targets[target](event.target.closest(target));
|
||||
currentTags() && event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function setupQuickTag() {
|
||||
|
||||
if (getTagButton() && currentTags()) toggleActiveState();
|
||||
if (getTagButton()) onLeftClick(clickHandler);
|
||||
|
||||
}
|
||||
|
||||
export { setupQuickTag };
|
71
assets/js/resizablemedia.js
Normal file
71
assets/js/resizablemedia.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
let mediaContainers;
|
||||
|
||||
/* Hardcoded dimensions of thumb boxes; at mediaLargeMinSize, large box becomes a small one (font size gets diminished).
|
||||
* At minimum width, the large box still has four digit fave/score numbers and five digit comment number fitting in a single row
|
||||
* (small box may lose the number of comments in a hidden overflow) */
|
||||
const mediaLargeMaxSize = 250, mediaLargeMinSize = 190, mediaSmallMaxSize = 156, mediaSmallMinSize = 140;
|
||||
/* Margin between thumbs (6) + borders (2) + 1 extra px to correct rounding errors */
|
||||
const mediaBoxOffset = 9;
|
||||
|
||||
export function processResizableMedia() {
|
||||
[].slice.call(mediaContainers).forEach(container => {
|
||||
const containerHasLargeBoxes = container.querySelector('.media-box__content--large') !== null,
|
||||
containerWidth = container.offsetWidth - 14; /* subtract container padding */
|
||||
|
||||
/* If at least three large boxes fit in a single row, we do not downsize them to small ones.
|
||||
* This ensures that desktop users get less boxes in a row, but with bigger images inside. */
|
||||
const largeBoxesFitting = Math.floor(containerWidth / (mediaLargeMinSize + mediaBoxOffset));
|
||||
if (largeBoxesFitting >= 3) {
|
||||
/* At the same time, we don't want small boxes to be upscaled. */
|
||||
if (containerHasLargeBoxes) {
|
||||
/* Larger boxes are preferred to more items in a row */
|
||||
setMediaSize(container, containerWidth, mediaLargeMinSize, mediaLargeMaxSize);
|
||||
}
|
||||
}
|
||||
/* Mobile users, on the other hand, should get as many boxes in a row as possible */
|
||||
else {
|
||||
setMediaSize(container, containerWidth, mediaSmallMinSize, mediaSmallMaxSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyMediaSize(container, size) {
|
||||
const mediaItems = container.querySelectorAll('.media-box__content');
|
||||
|
||||
[].slice.call(mediaItems).forEach(item => {
|
||||
item.style.width = `${size}px`;
|
||||
item.style.height = `${size}px`;
|
||||
|
||||
const header = item.parentNode.childNodes[0];
|
||||
// TODO: Make this proper and/or rethink this entire croc of bullshit
|
||||
item.parentNode.style.width = `${size}px`;
|
||||
/* When the large box has width less than mediaLargeMinSize, the header gets wrapped and occupies more than one line.
|
||||
* To prevent that, we add a class that diminishes its padding and font size. */
|
||||
if (size < mediaLargeMinSize) {
|
||||
header.classList.add('media-box__header--small');
|
||||
}
|
||||
else {
|
||||
header.classList.remove('media-box__header--small');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setMediaSize(container, containerWidth, minMediaSize, maxMediaSize) {
|
||||
const maxThumbsFitting = Math.floor(containerWidth / (minMediaSize + mediaBoxOffset)),
|
||||
minThumbsFitting = Math.floor(containerWidth / (maxMediaSize + mediaBoxOffset)),
|
||||
fitThumbs = Math.round((maxThumbsFitting + minThumbsFitting) / 2),
|
||||
thumbSize = Math.max(Math.floor(containerWidth / fitThumbs) - 9, minMediaSize);
|
||||
|
||||
applyMediaSize(container, thumbSize);
|
||||
}
|
||||
|
||||
function initializeListener() {
|
||||
mediaContainers = document.querySelectorAll('.js-resizable-media-container');
|
||||
|
||||
if (mediaContainers.length > 0) {
|
||||
window.addEventListener('resize', processResizableMedia);
|
||||
processResizableMedia();
|
||||
}
|
||||
}
|
||||
|
||||
export { initializeListener };
|
45
assets/js/search.js
Normal file
45
assets/js/search.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { $, $$ } from './utils/dom';
|
||||
import { addTag } from './tagsinput';
|
||||
|
||||
function showHelp(subject, type) {
|
||||
$$('[data-search-help]').forEach(helpBox => {
|
||||
if (helpBox.getAttribute('data-search-help') === type) {
|
||||
$('.js-search-help-subject', helpBox).textContent = subject;
|
||||
helpBox.classList.remove('hidden');
|
||||
}
|
||||
else {
|
||||
helpBox.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prependToLast(field, value) {
|
||||
const separatorIndex = field.value.lastIndexOf(',');
|
||||
const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
|
||||
field.value = field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
|
||||
}
|
||||
|
||||
function selectLast(field, characterCount) {
|
||||
field.focus();
|
||||
|
||||
field.selectionStart = field.value.length - characterCount;
|
||||
field.selectionEnd = field.value.length;
|
||||
}
|
||||
|
||||
function executeFormHelper(e) {
|
||||
const searchField = $('.js-search-field');
|
||||
const attr = name => e.target.getAttribute(name);
|
||||
|
||||
attr('data-search-add') && addTag(searchField, attr('data-search-add'));
|
||||
attr('data-search-show-help') && showHelp(e.target.textContent, attr('data-search-show-help'));
|
||||
attr('data-search-select-last') && selectLast(searchField, parseInt(attr('data-search-select-last'), 10));
|
||||
attr('data-search-prepend') && prependToLast(searchField, attr('data-search-prepend'));
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const form = $('.js-search-form');
|
||||
|
||||
form && form.addEventListener('click', executeFormHelper);
|
||||
}
|
||||
|
||||
export { setupSearch };
|
29
assets/js/settings.js
Normal file
29
assets/js/settings.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Settings.
|
||||
*/
|
||||
|
||||
import { $, $$ } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
|
||||
export function setupSettings() {
|
||||
|
||||
if (!$('#js-setting-table')) return;
|
||||
|
||||
const localCheckboxes = $$('[data-tab="local"] input[type="checkbox"]');
|
||||
const themeSelect = $('#user_theme');
|
||||
const styleSheet = $('head link[rel="stylesheet"]');
|
||||
|
||||
// Local settings
|
||||
localCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = Boolean(store.get(checkbox.id));
|
||||
checkbox.addEventListener('change', () => {
|
||||
store.set(checkbox.id, checkbox.checked);
|
||||
});
|
||||
});
|
||||
|
||||
// Theme preview
|
||||
themeSelect && themeSelect.addEventListener('change', () => {
|
||||
styleSheet.href = themeSelect.options[themeSelect.selectedIndex].dataset.themePath;
|
||||
});
|
||||
|
||||
}
|
44
assets/js/shortcuts.js
Normal file
44
assets/js/shortcuts.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Keyboard shortcuts
|
||||
*/
|
||||
|
||||
function getHover() {
|
||||
const thumbBoxHover = document.querySelector('.media-box:hover');
|
||||
if (thumbBoxHover) return thumbBoxHover.dataset.imageId;
|
||||
}
|
||||
|
||||
function click(selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) el.click();
|
||||
}
|
||||
|
||||
function isOK(event) {
|
||||
return !event.altKey && !event.ctrlKey && !event.metaKey &&
|
||||
document.activeElement.tagName !== 'INPUT' &&
|
||||
document.activeElement.tagName !== 'TEXTAREA';
|
||||
}
|
||||
|
||||
const keyCodes = {
|
||||
74() { click('.js-prev'); }, // J - go to previous image
|
||||
73() { click('.js-up'); }, // I - go to index page
|
||||
75() { click('.js-next'); }, // K - go to next image
|
||||
82() { click('.js-rand'); }, // R - go to random image
|
||||
83() { click('.js-source-link'); }, // S - go to image source
|
||||
76() { click('.js-tag-sauce-toggle'); }, // L - edit tags
|
||||
70() { // F - favourite image
|
||||
getHover() ? click(`a.interaction--fave[data-image-id="${getHover()}"]`)
|
||||
: click('.block__header a.interaction--fave');
|
||||
},
|
||||
85() { // U - upvote image
|
||||
getHover() ? click(`a.interaction--upvote[data-image-id="${getHover()}"]`)
|
||||
: click('.block__header a.interaction--upvote');
|
||||
},
|
||||
};
|
||||
|
||||
function listenForKeys() {
|
||||
document.addEventListener('keydown', event => {
|
||||
if (isOK(event) && keyCodes[event.keyCode]) { keyCodes[event.keyCode](); event.preventDefault(); }
|
||||
});
|
||||
}
|
||||
|
||||
export { listenForKeys };
|
17
assets/js/staffhider.js
Normal file
17
assets/js/staffhider.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* StaffHider
|
||||
*
|
||||
* Hide staff elements if enabled in the settings.
|
||||
*/
|
||||
|
||||
import { $$ } from './utils/dom';
|
||||
|
||||
function hideStaffTools() {
|
||||
if (window.booru.hideStaffTools) {
|
||||
$$('.js-staff-action').forEach(el => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { hideStaffTools };
|
61
assets/js/tags.js
Normal file
61
assets/js/tags.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Tags Dropdown
|
||||
*/
|
||||
|
||||
import { showEl, hideEl } from './utils/dom';
|
||||
|
||||
function addTag(tagId, list) {
|
||||
list.push(tagId);
|
||||
}
|
||||
|
||||
function removeTag(tagId, list) {
|
||||
list.splice(list.indexOf(tagId), 1);
|
||||
}
|
||||
|
||||
function createTagDropdown(tag) {
|
||||
const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
|
||||
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = [].slice.call(tag.querySelectorAll('.tag__dropdown__link'));
|
||||
const [ unwatched, watched, spoilered, hidden ] = [].slice.call(tag.querySelectorAll('.tag__state'));
|
||||
const tagId = parseInt(tag.dataset.tagId, 10);
|
||||
|
||||
const actions = {
|
||||
unwatch() { hideEl(unwatch, watched); showEl(watch, unwatched); removeTag(tagId, watchedTagList); },
|
||||
watch() { hideEl(watch, unwatched); showEl(unwatch, watched); addTag(tagId, watchedTagList); },
|
||||
|
||||
unspoiler() { hideEl(unspoiler, spoilered); showEl(spoiler); removeTag(tagId, spoileredTagList); },
|
||||
spoiler() { hideEl(spoiler); showEl(unspoiler, spoilered); addTag(tagId, spoileredTagList); },
|
||||
|
||||
unhide() { hideEl(unhide, hidden); showEl(hide); removeTag(tagId, hiddenTagList); },
|
||||
hide() { hideEl(hide); showEl(unhide, hidden); addTag(tagId, hiddenTagList); },
|
||||
};
|
||||
|
||||
const tagIsWatched = watchedTagList.includes(tagId);
|
||||
const tagIsSpoilered = spoileredTagList.includes(tagId);
|
||||
const tagIsHidden = hiddenTagList.includes(tagId);
|
||||
|
||||
const watchedLink = tagIsWatched ? unwatch : watch;
|
||||
const spoilerLink = tagIsSpoilered ? unspoiler : spoiler;
|
||||
const hiddenLink = tagIsHidden ? unhide : hide;
|
||||
|
||||
// State symbols (-, S, H, +)
|
||||
if (tagIsWatched) showEl(watched);
|
||||
if (tagIsSpoilered) showEl(spoilered);
|
||||
if (tagIsHidden) showEl(hidden);
|
||||
if (!tagIsWatched) showEl(unwatched);
|
||||
|
||||
// Dropdown links
|
||||
if (userIsSignedIn) showEl(watchedLink);
|
||||
if (userCanEditFilter) showEl(spoilerLink);
|
||||
if (userCanEditFilter) showEl(hiddenLink);
|
||||
if (!userIsSignedIn) showEl(signIn);
|
||||
if (userIsSignedIn &&
|
||||
!userCanEditFilter) showEl(filter);
|
||||
|
||||
tag.addEventListener('fetchcomplete', event => actions[event.target.dataset.tagAction]());
|
||||
}
|
||||
|
||||
function initTagDropdown() {
|
||||
[].forEach.call(document.querySelectorAll('.tag.dropdown'), createTagDropdown);
|
||||
}
|
||||
|
||||
export { initTagDropdown };
|
141
assets/js/tagsinput.js
Normal file
141
assets/js/tagsinput.js
Normal file
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Fancy tag editor.
|
||||
*/
|
||||
|
||||
import { $, $$, clearEl, removeEl, showEl, hideEl, escapeHtml } from './utils/dom';
|
||||
|
||||
function setupTagsInput(tagBlock) {
|
||||
const [ textarea, container ] = $$('.js-taginput', tagBlock);
|
||||
const setup = $('.js-tag-block ~ button', tagBlock.parentNode);
|
||||
const inputField = $('input', container);
|
||||
|
||||
let tags = [];
|
||||
|
||||
// Load in the current tag set from the textarea
|
||||
setup.addEventListener('click', importTags);
|
||||
|
||||
// Respond to tags being added
|
||||
textarea.addEventListener('addtag', handleAddTag);
|
||||
|
||||
// Respond to reload event
|
||||
textarea.addEventListener('reload', importTags);
|
||||
|
||||
// Respond to [x] clicks in the tag editor
|
||||
tagBlock.addEventListener('click', handleTagClear);
|
||||
|
||||
// Respond to key sequences in the input field
|
||||
inputField.addEventListener('keydown', handleKeyEvent);
|
||||
|
||||
// Respond to autocomplete form clicks
|
||||
inputField.addEventListener('autocomplete', handleAutocomplete);
|
||||
|
||||
// TODO: Cleanup this bug fix
|
||||
// Switch to fancy tagging if user settings want it
|
||||
if (fancyEditorRequested(tagBlock)) {
|
||||
showEl($$('.js-taginput-fancy'));
|
||||
showEl($$('.js-taginput-hide'));
|
||||
hideEl($$('.js-taginput-plain'));
|
||||
hideEl($$('.js-taginput-show'));
|
||||
importTags();
|
||||
}
|
||||
|
||||
|
||||
function handleAutocomplete(event) {
|
||||
insertTag(event.detail.value);
|
||||
inputField.focus();
|
||||
}
|
||||
|
||||
function handleAddTag(event) {
|
||||
// Ignore if not in tag edit mode
|
||||
if (container.classList.contains('hidden')) return;
|
||||
|
||||
insertTag(event.detail.name);
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function handleTagClear(event) {
|
||||
if (event.target.dataset.tagName) {
|
||||
event.preventDefault();
|
||||
removeTag(event.target.dataset.tagName, event.target.parentNode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyEvent(event) {
|
||||
const { keyCode, ctrlKey } = event;
|
||||
|
||||
// allow form submission with ctrl+enter if no text was typed
|
||||
if (keyCode === 13 && ctrlKey && inputField.value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// backspace on a blank input field
|
||||
if (keyCode === 8 && inputField.value === '') {
|
||||
event.preventDefault();
|
||||
const erased = $('.tag:last-of-type', container);
|
||||
|
||||
if (erased) removeTag(tags[tags.length - 1], erased);
|
||||
}
|
||||
|
||||
// enter or comma
|
||||
if (keyCode === 13 || keyCode === 188) {
|
||||
event.preventDefault();
|
||||
inputField.value.split(',').forEach(t => insertTag(t));
|
||||
inputField.value = '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function insertTag(name) {
|
||||
name = name.trim(); // eslint-disable-line no-param-reassign
|
||||
|
||||
// Add if not degenerate or already present
|
||||
if (name.length === 0 || tags.indexOf(name) !== -1) return;
|
||||
tags.push(name);
|
||||
textarea.value = tags.join(', ');
|
||||
|
||||
// Insert the new element
|
||||
const el = `<span class="tag">${escapeHtml(name)} <a href="#" data-click-focus=".js-taginput-input" data-tag-name="${escapeHtml(name)}">x</a></span>`;
|
||||
inputField.insertAdjacentHTML('beforebegin', el);
|
||||
inputField.value = '';
|
||||
}
|
||||
|
||||
function removeTag(name, element) {
|
||||
removeEl(element);
|
||||
|
||||
// Remove the tag from the list
|
||||
tags.splice(tags.indexOf(name), 1);
|
||||
textarea.value = tags.join(', ');
|
||||
}
|
||||
|
||||
function importTags() {
|
||||
clearEl(container);
|
||||
container.appendChild(inputField);
|
||||
|
||||
tags = [];
|
||||
textarea.value.split(',').forEach(t => insertTag(t));
|
||||
textarea.value = tags.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
function fancyEditorRequested(tagBlock) {
|
||||
// Check whether the user made the fancy editor the default for each type of tag block.
|
||||
return window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload') ||
|
||||
window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit');
|
||||
}
|
||||
|
||||
function setupTagListener() {
|
||||
document.addEventListener('addtag', event => {
|
||||
if (event.target.value) event.target.value += ', ';
|
||||
event.target.value += event.detail.name;
|
||||
});
|
||||
}
|
||||
|
||||
function addTag(textarea, name) {
|
||||
textarea.dispatchEvent(new CustomEvent('addtag', { detail: { name }, bubbles: true }));
|
||||
}
|
||||
|
||||
function reloadTagsInput(textarea) {
|
||||
textarea.dispatchEvent(new CustomEvent('reload'));
|
||||
}
|
||||
|
||||
export { setupTagsInput, setupTagListener, addTag, reloadTagsInput };
|
56
assets/js/tagsmisc.js
Normal file
56
assets/js/tagsmisc.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Tags Misc
|
||||
*/
|
||||
|
||||
import { $$ } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
import { initTagDropdown} from './tags';
|
||||
import { setupTagsInput, reloadTagsInput } from './tagsinput';
|
||||
|
||||
function tagInputButtons({target}) {
|
||||
const actions = {
|
||||
save(tagInput) {
|
||||
store.set('tag_input', tagInput.value);
|
||||
},
|
||||
load(tagInput) {
|
||||
// If entry 'tag_input' does not exist, try to use the current list
|
||||
tagInput.value = store.get('tag_input') || tagInput.value;
|
||||
reloadTagsInput(tagInput);
|
||||
},
|
||||
clear(tagInput) {
|
||||
tagInput.value = '';
|
||||
reloadTagsInput(tagInput);
|
||||
},
|
||||
};
|
||||
|
||||
for (const action in actions) {
|
||||
if (target.matches(`#tagsinput-${action}`)) actions[action](document.getElementById('image_tag_input'));
|
||||
}
|
||||
}
|
||||
|
||||
function setupTags() {
|
||||
$$('.js-tag-block').forEach(el => {
|
||||
setupTagsInput(el);
|
||||
el.classList.remove('js-tag-block');
|
||||
});
|
||||
}
|
||||
|
||||
function updateTagSauce({target, detail}) {
|
||||
const tagSauce = document.querySelector('.js-tagsauce');
|
||||
|
||||
if (target.matches('#tags-form')) {
|
||||
detail.text().then(text => {
|
||||
tagSauce.outerHTML = text;
|
||||
setupTags();
|
||||
initTagDropdown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupTagEvents() {
|
||||
setupTags();
|
||||
document.addEventListener('fetchcomplete', updateTagSauce);
|
||||
document.addEventListener('click', tagInputButtons);
|
||||
}
|
||||
|
||||
export { setupTagEvents };
|
172
assets/js/textiletoolbar.js
Normal file
172
assets/js/textiletoolbar.js
Normal file
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Textile toolbar
|
||||
*
|
||||
*/
|
||||
|
||||
import { $, $$ } from './utils/dom';
|
||||
|
||||
const textileSyntax = {
|
||||
bold: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '*', suffix: '*', shortcutKey: 'b' }
|
||||
},
|
||||
italics: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '_', suffix: '_', shortcutKey: 'i' }
|
||||
},
|
||||
under: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '+', suffix: '+', shortcutKey: 'u' }
|
||||
},
|
||||
spoiler: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '[spoiler]', suffix: '[/spoiler]', shortcutKey: 's' }
|
||||
},
|
||||
code: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '@', suffix: '@', shortcutKey: 'e' }
|
||||
},
|
||||
strike: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '-', suffix: '-' }
|
||||
},
|
||||
superscript: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '^', suffix: '^' }
|
||||
},
|
||||
subscript: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '~', suffix: '~' }
|
||||
},
|
||||
quote: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '[bq]', suffix: '[/bq]' }
|
||||
},
|
||||
link: {
|
||||
action: insertLink,
|
||||
options: { prefix: '"', suffix: '":', shortcutKey: 'l' }
|
||||
},
|
||||
image: {
|
||||
action: insertImage,
|
||||
options: { prefix: '!', suffix: '!', shortcutKey: 'k' }
|
||||
},
|
||||
noParse: {
|
||||
action: wrapSelection,
|
||||
options: { prefix: '[==', suffix: '==]' }
|
||||
},
|
||||
};
|
||||
|
||||
function getSelections(textarea) {
|
||||
let selection = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd),
|
||||
leadingSpace = '',
|
||||
trailingSpace = '',
|
||||
caret;
|
||||
|
||||
// Deselect trailing space and line break
|
||||
for (caret = selection.length - 1; caret > 0; caret--) {
|
||||
if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
|
||||
trailingSpace = selection[caret] + trailingSpace;
|
||||
}
|
||||
selection = selection.substring(0, caret + 1);
|
||||
|
||||
// Deselect leading space and line break
|
||||
for (caret = 0; caret < selection.length; caret++) {
|
||||
if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
|
||||
leadingSpace += selection[caret];
|
||||
}
|
||||
selection = selection.substring(caret);
|
||||
|
||||
return {
|
||||
selectedText: selection,
|
||||
beforeSelection: textarea.value.substring(0, textarea.selectionStart) + leadingSpace,
|
||||
afterSelection: trailingSpace + textarea.value.substring(textarea.selectionEnd),
|
||||
};
|
||||
}
|
||||
|
||||
function wrapSelection(textarea, options) {
|
||||
const { selectedText, beforeSelection, afterSelection } = getSelections(textarea),
|
||||
{ text = selectedText, prefix = '', suffix = '' } = options,
|
||||
// For long comments, record scrollbar position to restore it later
|
||||
scrollTop = textarea.scrollTop,
|
||||
emptyText = text === '';
|
||||
|
||||
let newText = text;
|
||||
if (!emptyText && prefix[0] !== '[') {
|
||||
newText = text.replace(/(\n{2,})/g, match => {
|
||||
return suffix + match + prefix;
|
||||
});
|
||||
}
|
||||
|
||||
textarea.value = beforeSelection + prefix + newText + suffix + afterSelection;
|
||||
|
||||
// If no text were highlighted, place the caret inside
|
||||
// the formatted section, otherwise place it at the end
|
||||
if (emptyText) {
|
||||
textarea.selectionEnd = textarea.value.length - afterSelection.length - suffix.length;
|
||||
}
|
||||
else {
|
||||
textarea.selectionEnd = textarea.value.length - afterSelection.length;
|
||||
}
|
||||
textarea.selectionStart = textarea.selectionEnd;
|
||||
textarea.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
function insertLink(textarea, options) {
|
||||
let hyperlink = window.prompt('Link:');
|
||||
if (!hyperlink || hyperlink === '') return;
|
||||
|
||||
// Change on-site link to use relative url
|
||||
if (hyperlink.startsWith(window.location.origin)) hyperlink = hyperlink.substring(window.location.origin.length);
|
||||
|
||||
const prefix = options.prefix,
|
||||
suffix = options.suffix + hyperlink;
|
||||
|
||||
wrapSelection(textarea, { prefix, suffix });
|
||||
}
|
||||
|
||||
function insertImage(textarea, options) {
|
||||
const hyperlink = window.prompt('Image link:');
|
||||
const { prefix, suffix } = options;
|
||||
|
||||
if (!hyperlink || hyperlink === '') return;
|
||||
|
||||
wrapSelection(textarea, { text: hyperlink, prefix, suffix });
|
||||
}
|
||||
|
||||
function clickHandler(event) {
|
||||
const button = event.target.closest('.communication__toolbar__button');
|
||||
if (!button) return;
|
||||
const toolbar = button.closest('.communication__toolbar'),
|
||||
// There may be multiple toolbars present on the page,
|
||||
// in the case of image pages with description edit active
|
||||
// we target the textarea that shares the same parent as the toolabr
|
||||
textarea = $('.js-toolbar-input', toolbar.parentNode),
|
||||
id = button.dataset.syntaxId;
|
||||
|
||||
textileSyntax[id].action(textarea, textileSyntax[id].options);
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
function shortcutHandler(event) {
|
||||
if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return;
|
||||
const textarea = event.target,
|
||||
key = event.key.toLowerCase();
|
||||
|
||||
for (const id in textileSyntax) {
|
||||
if (key === textileSyntax[id].options.shortcutKey) {
|
||||
textileSyntax[id].action(textarea, textileSyntax[id].options);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupToolbar() {
|
||||
$$('.communication__toolbar').forEach(toolbar => {
|
||||
toolbar.addEventListener('click', clickHandler);
|
||||
});
|
||||
$$('.js-toolbar-input').forEach(textarea => {
|
||||
textarea.addEventListener('keydown', shortcutHandler);
|
||||
});
|
||||
}
|
||||
|
||||
export { setupToolbar };
|
68
assets/js/timeago.js
Normal file
68
assets/js/timeago.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Frontend timestamps.
|
||||
*/
|
||||
|
||||
const strings = {
|
||||
seconds: 'less than a minute',
|
||||
minute: 'about a minute',
|
||||
minutes: '%d minutes',
|
||||
hour: 'about an hour',
|
||||
hours: 'about %d hours',
|
||||
day: 'a day',
|
||||
days: '%d days',
|
||||
month: 'about a month',
|
||||
months: '%d months',
|
||||
year: 'about a year',
|
||||
years: '%d years',
|
||||
};
|
||||
|
||||
function distance(time) {
|
||||
return new Date() - time;
|
||||
}
|
||||
|
||||
function substitute(key, amount) {
|
||||
return strings[key].replace('%d', Math.round(amount));
|
||||
}
|
||||
|
||||
function setTimeAgo(el) {
|
||||
const date = new Date(el.getAttribute('datetime'));
|
||||
const distMillis = distance(date);
|
||||
|
||||
const seconds = Math.abs(distMillis) / 1000,
|
||||
minutes = seconds / 60,
|
||||
hours = minutes / 60,
|
||||
days = hours / 24,
|
||||
months = days / 30,
|
||||
years = days / 365;
|
||||
|
||||
const words =
|
||||
seconds < 45 && substitute('seconds', seconds) ||
|
||||
seconds < 90 && substitute('minute', 1) ||
|
||||
minutes < 45 && substitute('minutes', minutes) ||
|
||||
minutes < 90 && substitute('hour', 1) ||
|
||||
hours < 24 && substitute('hours', hours) ||
|
||||
hours < 42 && substitute('day', 1) ||
|
||||
days < 30 && substitute('days', days) ||
|
||||
days < 45 && substitute('month', 1) ||
|
||||
days < 365 && substitute('months', months) ||
|
||||
years < 1.5 && substitute('year', 1) ||
|
||||
substitute('years', years);
|
||||
|
||||
if (!el.getAttribute('title')) {
|
||||
el.setAttribute('title', el.textContent);
|
||||
}
|
||||
el.textContent = words + (distMillis < 0 ? ' from now' : ' ago');
|
||||
}
|
||||
|
||||
function timeAgo(args) {
|
||||
[].forEach.call(args, el => setTimeAgo(el));
|
||||
}
|
||||
|
||||
function setupTimestamps() {
|
||||
timeAgo(document.getElementsByTagName('time'));
|
||||
window.setTimeout(setupTimestamps, 60000);
|
||||
}
|
||||
|
||||
export { setupTimestamps };
|
||||
|
||||
window.booru.timeAgo = timeAgo;
|
106
assets/js/ujs.js
Normal file
106
assets/js/ujs.js
Normal file
|
@ -0,0 +1,106 @@
|
|||
import { $$, makeEl, findFirstTextNode } from './utils/dom';
|
||||
import { fire, delegate } from './utils/events';
|
||||
|
||||
const headers = () => ({
|
||||
Accept: 'text/javascript',
|
||||
'X-CSRF-Token': window.booru.csrfToken
|
||||
});
|
||||
|
||||
function confirm(event, target) {
|
||||
if (!window.confirm(target.dataset.confirm)) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function disable(event, target) {
|
||||
// failed validations prevent the form from being submitted;
|
||||
// stop here or the form will be permanently locked
|
||||
if (target.type === 'submit' && target.closest(':invalid') !== null) return;
|
||||
|
||||
// Store what's already there so we don't lose it
|
||||
const label = findFirstTextNode(target);
|
||||
if (label) {
|
||||
target.dataset.enableWith = label.nodeValue;
|
||||
label.nodeValue = ` ${target.dataset.disableWith}`;
|
||||
}
|
||||
else {
|
||||
target.dataset.enableWith = target.innerHTML;
|
||||
target.innerHTML = target.dataset.disableWith;
|
||||
}
|
||||
|
||||
// delay is needed because Safari stops the submit if the button is immediately disabled
|
||||
requestAnimationFrame(() => target.disabled = 'disabled');
|
||||
}
|
||||
|
||||
// you should use button_to instead of link_to[method]!
|
||||
function linkMethod(event, target) {
|
||||
event.preventDefault();
|
||||
|
||||
const form = makeEl('form', { action: target.href, method: 'POST' });
|
||||
const csrf = makeEl('input', { type: 'hidden', name: window.booru.csrfParam, value: window.booru.csrfToken });
|
||||
const method = makeEl('input', { type: 'hidden', name: '_method', value: target.dataset.method });
|
||||
|
||||
document.body.appendChild(form);
|
||||
|
||||
form.appendChild(csrf);
|
||||
form.appendChild(method);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function formRemote(event, target) {
|
||||
event.preventDefault();
|
||||
|
||||
fetch(target.action, {
|
||||
credentials: 'same-origin',
|
||||
method: (target.dataset.method || target.method || 'POST').toUpperCase(),
|
||||
headers: headers(),
|
||||
body: new FormData(target)
|
||||
}).then(response =>
|
||||
fire(target, 'fetchcomplete', response)
|
||||
);
|
||||
}
|
||||
|
||||
function formReset(event, target) {
|
||||
$$('[disabled][data-disable-with]', target).forEach(input => {
|
||||
const label = findFirstTextNode(input);
|
||||
if (label) {
|
||||
label.nodeValue = ` ${input.dataset.enableWith}`;
|
||||
}
|
||||
else { input.innerHTML = target.dataset.enableWith; }
|
||||
delete input.dataset.enableWith;
|
||||
input.removeAttribute('disabled');
|
||||
});
|
||||
}
|
||||
|
||||
function linkRemote(event, target) {
|
||||
event.preventDefault();
|
||||
|
||||
fetch(target.href, {
|
||||
credentials: 'same-origin',
|
||||
method: target.dataset.method.toUpperCase(),
|
||||
headers: headers()
|
||||
}).then(response =>
|
||||
fire(target, 'fetchcomplete', response)
|
||||
);
|
||||
}
|
||||
|
||||
function leftClick(func) {
|
||||
return (event, target) => { if (event.button === 0) return func(event, target); };
|
||||
}
|
||||
|
||||
delegate(document, 'click', {
|
||||
'a[data-confirm],button[data-confirm],input[data-confirm]': leftClick(confirm),
|
||||
'a[data-disable-with],button[data-disable-with],input[data-disable-with]': leftClick(disable),
|
||||
'a[data-method]:not([data-remote])': leftClick(linkMethod),
|
||||
'a[data-remote]': leftClick(linkRemote),
|
||||
});
|
||||
|
||||
delegate(document, 'submit', {
|
||||
'form[data-remote]': formRemote
|
||||
});
|
||||
|
||||
delegate(document, 'reset', {
|
||||
form: formReset
|
||||
});
|
105
assets/js/upload.js
Normal file
105
assets/js/upload.js
Normal file
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Fetch and display preview images for various image upload forms.
|
||||
*/
|
||||
|
||||
import { fetchJson, handleError } from './utils/requests';
|
||||
import { $, $$, hideEl, showEl, makeEl, clearEl } from './utils/dom';
|
||||
import { addTag } from './tagsinput';
|
||||
|
||||
function scrapeUrl(url) {
|
||||
return fetchJson('POST', '/images/scrape_url', { url })
|
||||
.then(handleError)
|
||||
.then(response => response.json());
|
||||
}
|
||||
|
||||
function setupImageUpload() {
|
||||
const imgPreviews = $('#js-image-upload-previews');
|
||||
if (!imgPreviews) return;
|
||||
|
||||
const form = imgPreviews.closest('form');
|
||||
const [ fileField, remoteUrl, scraperError ] = $$('.js-scraper', form);
|
||||
const [ sourceEl, tagsEl, descrEl ] = $$('.js-image-input', form);
|
||||
const fetchButton = $('#js-scraper-preview');
|
||||
|
||||
function showImages(images) {
|
||||
clearEl(imgPreviews);
|
||||
|
||||
images.forEach((image, index) => {
|
||||
const img = makeEl('img', { className: 'scraper-preview--image' });
|
||||
img.src = image.camo_url;
|
||||
const imgWrap = makeEl('span', { className: 'scraper-preview--image-wrapper' });
|
||||
imgWrap.appendChild(img);
|
||||
|
||||
const label = makeEl('label', { className: 'scraper-preview--label' });
|
||||
const radio = makeEl('input', {
|
||||
type: 'radio',
|
||||
className: 'scraper-preview--input',
|
||||
});
|
||||
if (image.url) {
|
||||
radio.name = 'scraper_cache';
|
||||
radio.value = image.url;
|
||||
}
|
||||
if (index === 0) {
|
||||
radio.checked = true;
|
||||
}
|
||||
label.appendChild(radio);
|
||||
label.appendChild(imgWrap);
|
||||
imgPreviews.appendChild(label);
|
||||
});
|
||||
}
|
||||
function showError() {
|
||||
clearEl(imgPreviews);
|
||||
showEl(scraperError);
|
||||
enableFetch();
|
||||
}
|
||||
function hideError() { hideEl(scraperError); }
|
||||
function disableFetch() { fetchButton.setAttribute('disabled', ''); }
|
||||
function enableFetch() { fetchButton.removeAttribute('disabled'); }
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.addEventListener('load', event => {
|
||||
showImages([{
|
||||
camo_url: event.target.result,
|
||||
}]);
|
||||
|
||||
// Clear any currently cached data, because the file field
|
||||
// has higher priority than the scraper:
|
||||
remoteUrl.value = '';
|
||||
hideError();
|
||||
});
|
||||
|
||||
// Watch for files added to the form
|
||||
fileField.addEventListener('change', () => { fileField.files.length && reader.readAsDataURL(fileField.files[0]); });
|
||||
|
||||
// Watch for [Fetch] clicks
|
||||
fetchButton.addEventListener('click', () => {
|
||||
if (!remoteUrl.value) return;
|
||||
|
||||
disableFetch();
|
||||
|
||||
scrapeUrl(remoteUrl.value).then(data => {
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
scraperError.innerText = data.errors.join(' ');
|
||||
showError();
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
|
||||
// Set source
|
||||
if (sourceEl) sourceEl.value = sourceEl.value || data.source_url || '';
|
||||
// Set description
|
||||
if (descrEl) descrEl.value = descrEl.value || data.description || '';
|
||||
// Add author
|
||||
if (tagsEl && data.author_name) addTag(tagsEl, `artist:${data.author_name.toLowerCase()}`);
|
||||
// Clear selected file, if any
|
||||
fileField.value = '';
|
||||
showImages(data.images);
|
||||
|
||||
enableFetch();
|
||||
}).catch(showError);
|
||||
});
|
||||
}
|
||||
|
||||
export { setupImageUpload };
|
13
assets/js/utils/array.js
Normal file
13
assets/js/utils/array.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
// http://stackoverflow.com/a/5306832/1726690
|
||||
function moveElement(array, from, to) {
|
||||
array.splice(to, 0, array.splice(from, 1)[0]);
|
||||
}
|
||||
|
||||
function arraysEqual(array1, array2) {
|
||||
for (let i = 0; i < array1.length; ++i) {
|
||||
if (array1[i] !== array2[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export { moveElement, arraysEqual };
|
69
assets/js/utils/dom.js
Normal file
69
assets/js/utils/dom.js
Normal file
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* DOM Utils
|
||||
*/
|
||||
|
||||
function $(selector, context = document) { // Get the first matching element
|
||||
const element = context.querySelector(selector);
|
||||
|
||||
return element || null;
|
||||
}
|
||||
|
||||
function $$(selector, context = document) { // Get every matching element as an array
|
||||
const elements = context.querySelectorAll(selector);
|
||||
|
||||
return [].slice.call(elements);
|
||||
}
|
||||
|
||||
function showEl(...elements) {
|
||||
[].concat(...elements).forEach(el => el.classList.remove('hidden'));
|
||||
}
|
||||
|
||||
function hideEl(...elements) {
|
||||
[].concat(...elements).forEach(el => el.classList.add('hidden'));
|
||||
}
|
||||
|
||||
function toggleEl(...elements) {
|
||||
[].concat(...elements).forEach(el => el.classList.toggle('hidden'));
|
||||
}
|
||||
|
||||
function clearEl(...elements) {
|
||||
[].concat(...elements).forEach(el => { while (el.firstChild) el.removeChild(el.firstChild); });
|
||||
}
|
||||
|
||||
function removeEl(...elements) {
|
||||
[].concat(...elements).forEach(el => el.parentNode.removeChild(el));
|
||||
}
|
||||
|
||||
function makeEl(tag, attr = {}) {
|
||||
const el = document.createElement(tag);
|
||||
for (const prop in attr) el[prop] = attr[prop];
|
||||
return el;
|
||||
}
|
||||
|
||||
function insertBefore(existingElement, newElement) {
|
||||
existingElement.parentNode.insertBefore(newElement, existingElement);
|
||||
}
|
||||
|
||||
function onLeftClick(callback, context = document) {
|
||||
context.addEventListener('click', event => {
|
||||
if (event.button === 0) callback(event);
|
||||
});
|
||||
}
|
||||
|
||||
function whenReady(callback) { // Execute a function when the DOM is ready
|
||||
if (document.readyState !== 'loading') callback();
|
||||
else document.addEventListener('DOMContentLoaded', callback);
|
||||
}
|
||||
|
||||
function escapeHtml(html) {
|
||||
return html.replace(/&/g, '&')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function findFirstTextNode(of) {
|
||||
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
|
||||
}
|
||||
|
||||
export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, findFirstTextNode };
|
70
assets/js/utils/draggable.js
Normal file
70
assets/js/utils/draggable.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { $$ } from './dom';
|
||||
|
||||
let dragSrcEl;
|
||||
|
||||
function dragStart(event, target) {
|
||||
target.classList.add('dragging');
|
||||
dragSrcEl = target;
|
||||
|
||||
if (event.dataTransfer.items.length === 0) {
|
||||
event.dataTransfer.setData('text/plain', '');
|
||||
}
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function dragOver(event) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function dragEnter(event, target) {
|
||||
target.classList.add('over');
|
||||
}
|
||||
|
||||
function dragLeave(event, target) {
|
||||
target.classList.remove('over');
|
||||
}
|
||||
|
||||
function drop(event, target) {
|
||||
event.preventDefault();
|
||||
|
||||
dragSrcEl.classList.remove('dragging');
|
||||
|
||||
if (dragSrcEl === target) return;
|
||||
|
||||
// divide the target element into two sets of coordinates
|
||||
// and determine how to act based on the relative mouse positioin
|
||||
const bbox = target.getBoundingClientRect();
|
||||
const detX = bbox.left + (bbox.width / 2);
|
||||
|
||||
if (event.clientX < detX) {
|
||||
target.insertAdjacentElement('beforebegin', dragSrcEl);
|
||||
}
|
||||
else {
|
||||
target.insertAdjacentElement('afterend', dragSrcEl);
|
||||
}
|
||||
}
|
||||
|
||||
function dragEnd(event, target) {
|
||||
dragSrcEl.classList.remove('dragging');
|
||||
|
||||
$$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
|
||||
}
|
||||
|
||||
function wrapper(func) {
|
||||
return function(event) {
|
||||
if (!event.target.closest) return;
|
||||
const target = event.target.closest('.drag-container [draggable]');
|
||||
if (target) func(event, target);
|
||||
};
|
||||
}
|
||||
|
||||
export function initDraggables() {
|
||||
document.addEventListener('dragstart', wrapper(dragStart));
|
||||
document.addEventListener('dragover', wrapper(dragOver));
|
||||
document.addEventListener('dragenter', wrapper(dragEnter));
|
||||
document.addEventListener('dragleave', wrapper(dragLeave));
|
||||
document.addEventListener('dragend', wrapper(dragEnd));
|
||||
document.addEventListener('drop', wrapper(drop));
|
||||
}
|
20
assets/js/utils/events.js
Normal file
20
assets/js/utils/events.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* DOM events
|
||||
*/
|
||||
|
||||
export function fire(el, event, detail) {
|
||||
el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
export function on(node, event, selector, func) {
|
||||
delegate(node, event, { [selector]: func });
|
||||
}
|
||||
|
||||
export function delegate(node, event, selectors) {
|
||||
node.addEventListener(event, e => {
|
||||
for (const selector in selectors) {
|
||||
const target = e.target.closest(selector);
|
||||
if (target && selectors[selector](e, target) === false) break;
|
||||
}
|
||||
});
|
||||
}
|
128
assets/js/utils/image.js
Normal file
128
assets/js/utils/image.js
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { clearEl } from './dom';
|
||||
import store from './store';
|
||||
|
||||
function showVideoThumb(img) {
|
||||
const size = img.dataset.size;
|
||||
const uris = JSON.parse(img.dataset.uris);
|
||||
const thumbUri = uris[size];
|
||||
|
||||
const vidEl = img.querySelector('video');
|
||||
if (!vidEl) return false;
|
||||
|
||||
const imgEl = img.querySelector('img');
|
||||
if (!imgEl || imgEl.classList.contains('hidden')) return false;
|
||||
|
||||
imgEl.classList.add('hidden');
|
||||
|
||||
vidEl.innerHTML = `
|
||||
<source src="${thumbUri}" type="video/webm"/>
|
||||
<source src="${thumbUri.replace(/webm$/, 'mp4')}" type="video/mp4"/>
|
||||
`;
|
||||
vidEl.classList.remove('hidden');
|
||||
vidEl.play();
|
||||
|
||||
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function showThumb(img) {
|
||||
const size = img.dataset.size;
|
||||
const uris = JSON.parse(img.dataset.uris);
|
||||
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
||||
|
||||
const picEl = img.querySelector('picture');
|
||||
if (!picEl) return showVideoThumb(img);
|
||||
|
||||
const imgEl = picEl.querySelector('img');
|
||||
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
|
||||
|
||||
if (store.get('serve_hidpi') && !thumbUri.endsWith('.gif')) {
|
||||
// Check whether the HiDPI option is enabled, and make an exception for GIFs due to their size
|
||||
imgEl.srcset = `${thumbUri} 1x, ${uris.medium} 2x`;
|
||||
}
|
||||
|
||||
imgEl.src = thumbUri;
|
||||
if (uris[size].indexOf('.webm') !== -1) {
|
||||
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.innerHTML = 'WebM';
|
||||
}
|
||||
else {
|
||||
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function showBlock(img) {
|
||||
img.querySelector('.image-filtered').classList.add('hidden');
|
||||
const imageShowClasses = img.querySelector('.image-show').classList;
|
||||
imageShowClasses.remove('hidden');
|
||||
imageShowClasses.add('spoiler-pending');
|
||||
}
|
||||
|
||||
function hideVideoThumb(img, spoilerUri, reason) {
|
||||
const vidEl = img.querySelector('video');
|
||||
if (!vidEl) return;
|
||||
|
||||
const imgEl = img.querySelector('img');
|
||||
const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
|
||||
if (!imgEl) return;
|
||||
|
||||
imgEl.classList.remove('hidden');
|
||||
imgEl.src = spoilerUri;
|
||||
imgOverlay.innerHTML = reason;
|
||||
imgOverlay.classList.remove('hidden');
|
||||
|
||||
clearEl(vidEl);
|
||||
vidEl.classList.add('hidden');
|
||||
vidEl.pause();
|
||||
}
|
||||
|
||||
function hideThumb(img, spoilerUri, reason) {
|
||||
const picEl = img.querySelector('picture');
|
||||
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
||||
|
||||
const imgEl = picEl.querySelector('img');
|
||||
const imgOverlay = img.querySelector('.js-spoiler-info-overlay');
|
||||
|
||||
if (!imgEl || imgEl.src.indexOf(spoilerUri) !== -1) return;
|
||||
|
||||
imgEl.srcset = '';
|
||||
imgEl.src = spoilerUri;
|
||||
imgOverlay.innerHTML = reason;
|
||||
imgOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function spoilerThumb(img, spoilerUri, reason) {
|
||||
hideThumb(img, spoilerUri, reason);
|
||||
|
||||
switch (window.booru.spoilerType) {
|
||||
case 'click':
|
||||
img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); });
|
||||
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
|
||||
break;
|
||||
case 'hover':
|
||||
img.addEventListener('mouseenter', () => showThumb(img));
|
||||
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function spoilerBlock(img, spoilerUri, reason) {
|
||||
const imgEl = img.querySelector('.image-filtered img');
|
||||
const imgReason = img.querySelector('.filter-explanation');
|
||||
|
||||
if (!imgEl) return;
|
||||
|
||||
imgEl.src = spoilerUri;
|
||||
imgReason.innerHTML = reason;
|
||||
|
||||
img.querySelector('.image-show').classList.add('hidden');
|
||||
img.querySelector('.image-filtered').classList.remove('hidden');
|
||||
}
|
||||
|
||||
export { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb };
|
40
assets/js/utils/requests.js
Normal file
40
assets/js/utils/requests.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Request Utils
|
||||
*/
|
||||
|
||||
function fetchJson(verb, endpoint, body) {
|
||||
const data = {
|
||||
method: verb,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.booru.csrfToken,
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
body._method = verb;
|
||||
data.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return fetch(endpoint, data);
|
||||
}
|
||||
|
||||
function fetchHtml(endpoint) {
|
||||
return fetch(endpoint, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-Token': window.booru.csrfToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleError(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Received error from server');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export { fetchJson, fetchHtml, handleError };
|
66
assets/js/utils/store.js
Normal file
66
assets/js/utils/store.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* localStorage utils
|
||||
*/
|
||||
|
||||
const lastUpdatedSuffix = '__lastUpdated';
|
||||
|
||||
export default {
|
||||
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
get(key) {
|
||||
const value = localStorage.getItem(key);
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
catch (err) {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Watch changes to a specified key - returns value on change
|
||||
watch(key, callback) {
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === key) callback(this.get(key));
|
||||
});
|
||||
},
|
||||
|
||||
// set() with an additional key containing the current time + expiration time
|
||||
setWithExpireTime(key, value, maxAge) {
|
||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||
const lastUpdatedTime = Date.now() + maxAge;
|
||||
|
||||
this.set(key, value) && this.set(lastUpdatedKey, lastUpdatedTime);
|
||||
},
|
||||
|
||||
// Whether the value of a key set with setWithExpireTime() has expired
|
||||
hasExpired(key) {
|
||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||
const lastUpdatedTime = this.get(lastUpdatedKey);
|
||||
|
||||
if (Date.now() > lastUpdatedTime) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
};
|
58
assets/js/utils/tag.js
Normal file
58
assets/js/utils/tag.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { escapeHtml } from './dom';
|
||||
import { getTag } from '../booru';
|
||||
|
||||
function unique(array) {
|
||||
return array.filter((a, b, c) => c.indexOf(a) === b);
|
||||
}
|
||||
|
||||
function sortTags(hidden, a, b) {
|
||||
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
|
||||
if (a.spoiler_image_uri && b.spoiler_image_uri) {
|
||||
return hidden ? b.images - a.images : a.images - b.images;
|
||||
}
|
||||
// If neither has a spoiler image, sort by images count desc
|
||||
else if (!a.spoiler_image_uri && !b.spoiler_image_uri) {
|
||||
return b.images - a.images;
|
||||
}
|
||||
|
||||
// Tag with spoiler image takes precedence
|
||||
return a.spoiler_image_uri ? -1 : 1;
|
||||
}
|
||||
|
||||
function getHiddenTags() {
|
||||
return unique(window.booru.hiddenTagList)
|
||||
.map(tagId => getTag(tagId))
|
||||
.sort(sortTags.bind(null, true));
|
||||
}
|
||||
|
||||
function getSpoileredTags() {
|
||||
if (window.booru.spoilerType === 'off') return [];
|
||||
|
||||
return unique(window.booru.spoileredTagList)
|
||||
.filter(tagId => window.booru.ignoredTagList.indexOf(tagId) === -1)
|
||||
.map(tagId => getTag(tagId))
|
||||
.sort(sortTags.bind(null, false));
|
||||
}
|
||||
|
||||
function imageHitsTags(img, matchTags) {
|
||||
const imageTags = JSON.parse(img.dataset.imageTags);
|
||||
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
|
||||
}
|
||||
|
||||
function imageHitsComplex(img, matchComplex) {
|
||||
return matchComplex.hitsImage(img);
|
||||
}
|
||||
|
||||
function displayTags(tags) {
|
||||
const mainTag = tags[0], otherTags = tags.slice(1);
|
||||
let list = escapeHtml(mainTag.name), extras;
|
||||
|
||||
if (otherTags.length > 0) {
|
||||
extras = otherTags.map(tag => escapeHtml(tag.name)).join(', ');
|
||||
list += `<span title="${extras}">, ${extras}</span>`;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };
|
31
assets/js/vendor/closest.polyfill.js
vendored
Normal file
31
assets/js/vendor/closest.polyfill.js
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
// element-closest | CC0-1.0 | github.com/jonathantneal/closest
|
||||
|
||||
if (typeof Element.prototype.matches !== 'function') {
|
||||
Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.webkitMatchesSelector || function matches(selector) {
|
||||
var element = this;
|
||||
var elements = (element.document || element.ownerDocument).querySelectorAll(selector);
|
||||
var index = 0;
|
||||
|
||||
while (elements[index] && elements[index] !== element) {
|
||||
++index;
|
||||
}
|
||||
|
||||
return Boolean(elements[index]);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof Element.prototype.closest !== 'function') {
|
||||
Element.prototype.closest = function closest(selector) {
|
||||
var element = this;
|
||||
|
||||
while (element && element.nodeType === 1) {
|
||||
if (element.matches(selector)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
element = element.parentNode;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
17
assets/js/vendor/customevent.polyfill.js
vendored
Normal file
17
assets/js/vendor/customevent.polyfill.js
vendored
Normal file
|
@ -0,0 +1,17 @@
|
|||
// https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
|
||||
|
||||
(function () {
|
||||
|
||||
if ( typeof window.CustomEvent === "function" ) return false;
|
||||
|
||||
function CustomEvent ( event, params ) {
|
||||
params = params || { bubbles: false, cancelable: false, detail: undefined };
|
||||
var evt = document.createEvent( 'CustomEvent' );
|
||||
evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
|
||||
return evt;
|
||||
}
|
||||
|
||||
CustomEvent.prototype = window.Event.prototype;
|
||||
|
||||
window.CustomEvent = CustomEvent;
|
||||
})();
|
104
assets/js/vendor/es6.polyfill.js
vendored
Normal file
104
assets/js/vendor/es6.polyfill.js
vendored
Normal file
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* ES6 methods polyfill
|
||||
* Sourced from their respective articles on MDN
|
||||
*/
|
||||
|
||||
if (!Array.prototype.find) {
|
||||
Array.prototype.find = function(predicate) {
|
||||
'use strict';
|
||||
if (this == null) {
|
||||
throw new TypeError('Array.prototype.find called on null or undefined');
|
||||
}
|
||||
if (typeof predicate !== 'function') {
|
||||
throw new TypeError('predicate must be a function');
|
||||
}
|
||||
var list = Object(this);
|
||||
var length = list.length >>> 0;
|
||||
var thisArg = arguments[1];
|
||||
var value;
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
value = list[i];
|
||||
if (predicate.call(thisArg, value, i, list)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.prototype.findIndex) {
|
||||
Array.prototype.findIndex = function(predicate) {
|
||||
'use strict';
|
||||
if (this == null) {
|
||||
throw new TypeError('Array.prototype.findIndex called on null or undefined');
|
||||
}
|
||||
if (typeof predicate !== 'function') {
|
||||
throw new TypeError('predicate must be a function');
|
||||
}
|
||||
var list = Object(this);
|
||||
var length = list.length >>> 0;
|
||||
var thisArg = arguments[1];
|
||||
var value;
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
value = list[i];
|
||||
if (predicate.call(thisArg, value, i, list)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.prototype.includes) {
|
||||
Array.prototype.includes = function(searchElement /*, fromIndex*/) {
|
||||
'use strict';
|
||||
if (this == null) {
|
||||
throw new TypeError('Array.prototype.includes called on null or undefined');
|
||||
}
|
||||
|
||||
var O = Object(this);
|
||||
var len = parseInt(O.length, 10) || 0;
|
||||
if (len === 0) {
|
||||
return false;
|
||||
}
|
||||
var n = parseInt(arguments[1], 10) || 0;
|
||||
var k;
|
||||
if (n >= 0) {
|
||||
k = n;
|
||||
} else {
|
||||
k = len + n;
|
||||
if (k < 0) {k = 0;}
|
||||
}
|
||||
var currentElement;
|
||||
while (k < len) {
|
||||
currentElement = O[k];
|
||||
if (searchElement === currentElement ||
|
||||
(searchElement !== searchElement && currentElement !== currentElement)) { // NaN !== NaN
|
||||
return true;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
if (!String.prototype.startsWith) {
|
||||
String.prototype.startsWith = function(searchString, position){
|
||||
position = position || 0;
|
||||
return this.substr(position, searchString.length) === searchString;
|
||||
};
|
||||
}
|
||||
|
||||
if (!String.prototype.endsWith) {
|
||||
String.prototype.endsWith = function(searchString, position) {
|
||||
var subjectString = this.toString();
|
||||
if (typeof position !== 'number' || !isFinite(position) || Math.floor(position) !== position || position > subjectString.length) {
|
||||
position = subjectString.length;
|
||||
}
|
||||
position -= searchString.length;
|
||||
var lastIndex = subjectString.lastIndexOf(searchString, position);
|
||||
return lastIndex !== -1 && lastIndex === position;
|
||||
};
|
||||
}
|
53
assets/js/vendor/fetch.polyfill.js
vendored
Normal file
53
assets/js/vendor/fetch.polyfill.js
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
if (typeof window.fetch !== 'function') {
|
||||
window.fetch = function fetch(url, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request = new XMLHttpRequest();
|
||||
|
||||
options = options || {};
|
||||
request.open(options.method || 'GET', url);
|
||||
|
||||
for (const i in options.headers) {
|
||||
request.setRequestHeader(i, options.headers[i]);
|
||||
}
|
||||
|
||||
request.withCredentials = options.credentials === 'include' || options.credentials === 'same-origin';
|
||||
request.onload = () => resolve(response());
|
||||
request.onerror = reject;
|
||||
|
||||
// IE11 hack: don't send null/undefined
|
||||
if (options.body != null)
|
||||
request.send(options.body);
|
||||
else
|
||||
request.send();
|
||||
|
||||
function response() {
|
||||
const keys = [], all = [], headers = {};
|
||||
let header;
|
||||
|
||||
request.getAllResponseHeaders().replace(/^(.*?):\s*([\s\S]*?)$/gm, (m, key, value) => {
|
||||
keys.push(key = key.toLowerCase());
|
||||
all.push([key, value]);
|
||||
header = headers[key];
|
||||
headers[key] = header ? `${header},${value}` : value;
|
||||
});
|
||||
|
||||
return {
|
||||
ok: (request.status/200|0) === 1,
|
||||
status: request.status,
|
||||
statusText: request.statusText,
|
||||
url: request.responseURL,
|
||||
clone: response,
|
||||
text: () => Promise.resolve(request.responseText),
|
||||
json: () => Promise.resolve(request.responseText).then(JSON.parse),
|
||||
blob: () => Promise.resolve(new Blob([request.response])),
|
||||
headers: {
|
||||
keys: () => keys,
|
||||
entries: () => all,
|
||||
get: n => headers[n.toLowerCase()],
|
||||
has: n => n.toLowerCase() in headers
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
230
assets/js/vendor/promise.polyfill.js
vendored
Normal file
230
assets/js/vendor/promise.polyfill.js
vendored
Normal file
|
@ -0,0 +1,230 @@
|
|||
(function (root) {
|
||||
|
||||
// Store setTimeout reference so promise-polyfill will be unaffected by
|
||||
// other code modifying setTimeout (like sinon.useFakeTimers())
|
||||
var setTimeoutFunc = setTimeout;
|
||||
|
||||
function noop() {
|
||||
}
|
||||
|
||||
// Use polyfill for setImmediate for performance gains
|
||||
var asap = (typeof setImmediate === 'function' && setImmediate) ||
|
||||
function (fn) {
|
||||
setTimeoutFunc(fn, 1);
|
||||
};
|
||||
|
||||
var onUnhandledRejection = function onUnhandledRejection(err) {
|
||||
console.warn('Possible Unhandled Promise Rejection:', err); // eslint-disable-line no-console
|
||||
};
|
||||
|
||||
// Polyfill for Function.prototype.bind
|
||||
function bind(fn, thisArg) {
|
||||
return function () {
|
||||
fn.apply(thisArg, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
var isArray = Array.isArray || function (value) {
|
||||
return Object.prototype.toString.call(value) === '[object Array]';
|
||||
};
|
||||
|
||||
function Promise(fn) {
|
||||
if (typeof this !== 'object') throw new TypeError('Promises must be constructed via new');
|
||||
if (typeof fn !== 'function') throw new TypeError('not a function');
|
||||
this._state = 0;
|
||||
this._handled = false;
|
||||
this._value = undefined;
|
||||
this._deferreds = [];
|
||||
|
||||
doResolve(fn, this);
|
||||
}
|
||||
|
||||
function handle(self, deferred) {
|
||||
while (self._state === 3) {
|
||||
self = self._value;
|
||||
}
|
||||
if (self._state === 0) {
|
||||
self._deferreds.push(deferred);
|
||||
return;
|
||||
}
|
||||
self._handled = true;
|
||||
asap(function () {
|
||||
var cb = self._state === 1 ? deferred.onFulfilled : deferred.onRejected;
|
||||
if (cb === null) {
|
||||
(self._state === 1 ? resolve : reject)(deferred.promise, self._value);
|
||||
return;
|
||||
}
|
||||
var ret;
|
||||
try {
|
||||
ret = cb(self._value);
|
||||
} catch (e) {
|
||||
reject(deferred.promise, e);
|
||||
return;
|
||||
}
|
||||
resolve(deferred.promise, ret);
|
||||
});
|
||||
}
|
||||
|
||||
function resolve(self, newValue) {
|
||||
try {
|
||||
// Promise Resolution Procedure: https://github.com/promises-aplus/promises-spec#the-promise-resolution-procedure
|
||||
if (newValue === self) throw new TypeError('A promise cannot be resolved with itself.');
|
||||
if (newValue && (typeof newValue === 'object' || typeof newValue === 'function')) {
|
||||
var then = newValue.then;
|
||||
if (newValue instanceof Promise) {
|
||||
self._state = 3;
|
||||
self._value = newValue;
|
||||
finale(self);
|
||||
return;
|
||||
} else if (typeof then === 'function') {
|
||||
doResolve(bind(then, newValue), self);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self._state = 1;
|
||||
self._value = newValue;
|
||||
finale(self);
|
||||
} catch (e) {
|
||||
reject(self, e);
|
||||
}
|
||||
}
|
||||
|
||||
function reject(self, newValue) {
|
||||
self._state = 2;
|
||||
self._value = newValue;
|
||||
finale(self);
|
||||
}
|
||||
|
||||
function finale(self) {
|
||||
if (self._state === 2 && self._deferreds.length === 0) {
|
||||
setTimeout(function() {
|
||||
if (!self._handled) {
|
||||
onUnhandledRejection(self._value);
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
|
||||
for (var i = 0, len = self._deferreds.length; i < len; i++) {
|
||||
handle(self, self._deferreds[i]);
|
||||
}
|
||||
self._deferreds = null;
|
||||
}
|
||||
|
||||
function Handler(onFulfilled, onRejected, promise) {
|
||||
this.onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : null;
|
||||
this.onRejected = typeof onRejected === 'function' ? onRejected : null;
|
||||
this.promise = promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a potentially misbehaving resolver function and make sure
|
||||
* onFulfilled and onRejected are only called once.
|
||||
*
|
||||
* Makes no guarantees about asynchrony.
|
||||
*/
|
||||
function doResolve(fn, self) {
|
||||
var done = false;
|
||||
try {
|
||||
fn(function (value) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(self, value);
|
||||
}, function (reason) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
reject(self, reason);
|
||||
});
|
||||
} catch (ex) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
reject(self, ex);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.prototype['catch'] = function (onRejected) {
|
||||
return this.then(null, onRejected);
|
||||
};
|
||||
|
||||
Promise.prototype.then = function (onFulfilled, onRejected) {
|
||||
var prom = new Promise(noop);
|
||||
handle(this, new Handler(onFulfilled, onRejected, prom));
|
||||
return prom;
|
||||
};
|
||||
|
||||
Promise.all = function () {
|
||||
var args = Array.prototype.slice.call(arguments.length === 1 && isArray(arguments[0]) ? arguments[0] : arguments);
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (args.length === 0) return resolve([]);
|
||||
var remaining = args.length;
|
||||
|
||||
function res(i, val) {
|
||||
try {
|
||||
if (val && (typeof val === 'object' || typeof val === 'function')) {
|
||||
var then = val.then;
|
||||
if (typeof then === 'function') {
|
||||
then.call(val, function (val) {
|
||||
res(i, val);
|
||||
}, reject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
args[i] = val;
|
||||
if (--remaining === 0) {
|
||||
resolve(args);
|
||||
}
|
||||
} catch (ex) {
|
||||
reject(ex);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
res(i, args[i]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Promise.resolve = function (value) {
|
||||
if (value && typeof value === 'object' && value.constructor === Promise) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
resolve(value);
|
||||
});
|
||||
};
|
||||
|
||||
Promise.reject = function (value) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
reject(value);
|
||||
});
|
||||
};
|
||||
|
||||
Promise.race = function (values) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
for (var i = 0, len = values.length; i < len; i++) {
|
||||
values[i].then(resolve, reject);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the immediate function to execute callbacks
|
||||
* @param fn {function} Function to execute
|
||||
* @private
|
||||
*/
|
||||
Promise._setImmediateFn = function _setImmediateFn(fn) {
|
||||
asap = fn;
|
||||
};
|
||||
|
||||
Promise._setUnhandledRejectionFn = function _setUnhandledRejectionFn(fn) {
|
||||
onUnhandledRejection = fn;
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = Promise;
|
||||
} else if (!root.Promise) {
|
||||
root.Promise = Promise;
|
||||
}
|
||||
|
||||
})(window);
|
70
assets/js/when-ready.js
Normal file
70
assets/js/when-ready.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Functions to execute when the DOM is ready
|
||||
*/
|
||||
|
||||
import { whenReady } from './utils/dom';
|
||||
|
||||
import { showOwnedComments } from './communications/comment';
|
||||
import { showOwnedPosts } from './communications/post';
|
||||
|
||||
import { listenAutocomplete } from './autocomplete';
|
||||
import { loadBooruData } from './booru';
|
||||
import { registerEvents } from './boorujs';
|
||||
import { setupBurgerMenu } from './burger';
|
||||
import { bindCaptchaLinks } from './captcha';
|
||||
import { setupComments } from './comment';
|
||||
import { setupDupeReports } from './duplicate_reports.js';
|
||||
import { setFingerprintCookie } from './fingerprint';
|
||||
import { setupGalleryEditing } from './galleries';
|
||||
import { initImagesClientside } from './imagesclientside';
|
||||
import { bindImageTarget } from './image_expansion';
|
||||
import { setupEvents } from './misc';
|
||||
import { setupNotifications } from './notifications';
|
||||
import { setupPreviews } from './preview';
|
||||
import { setupQuickTag } from './quick-tag';
|
||||
import { initializeListener } from './resizablemedia';
|
||||
import { setupSettings } from './settings';
|
||||
import { listenForKeys } from './shortcuts';
|
||||
import { initTagDropdown } from './tags';
|
||||
import { setupTagListener } from './tagsinput';
|
||||
import { setupTagEvents } from './tagsmisc';
|
||||
import { setupTimestamps } from './timeago';
|
||||
import { setupImageUpload } from './upload';
|
||||
import { setupSearch } from './search';
|
||||
import { setupToolbar } from './textiletoolbar';
|
||||
import { hideStaffTools } from './staffhider';
|
||||
import { pollOptionCreator } from './poll';
|
||||
|
||||
whenReady(() => {
|
||||
|
||||
showOwnedComments();
|
||||
showOwnedPosts();
|
||||
loadBooruData();
|
||||
listenAutocomplete();
|
||||
registerEvents();
|
||||
setupBurgerMenu();
|
||||
bindCaptchaLinks();
|
||||
initImagesClientside();
|
||||
setupComments();
|
||||
setupDupeReports();
|
||||
setFingerprintCookie();
|
||||
setupGalleryEditing();
|
||||
bindImageTarget();
|
||||
setupEvents();
|
||||
setupNotifications();
|
||||
setupPreviews();
|
||||
setupQuickTag();
|
||||
initializeListener();
|
||||
setupSettings();
|
||||
listenForKeys();
|
||||
initTagDropdown();
|
||||
setupTagListener();
|
||||
setupTagEvents();
|
||||
setupTimestamps();
|
||||
setupImageUpload();
|
||||
setupSearch();
|
||||
setupToolbar();
|
||||
hideStaffTools();
|
||||
pollOptionCreator();
|
||||
|
||||
});
|
Loading…
Reference in a new issue