diff --git a/assets/js/app.js b/assets/js/app.js index b09fb1e5..9db37635 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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'; diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js new file mode 100644 index 00000000..b5e4d5de --- /dev/null +++ b/assets/js/autocomplete.js @@ -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 }; diff --git a/assets/js/booru.js b/assets/js/booru.js new file mode 100644 index 00000000..70db2221 --- /dev/null +++ b/assets/js/booru.js @@ -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 }; diff --git a/assets/js/boorujs.js b/assets/js/boorujs.js new file mode 100644 index 00000000..e5e4841e --- /dev/null +++ b/assets/js/boorujs.js @@ -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 }; diff --git a/assets/js/burger.js b/assets/js/burger.js new file mode 100644 index 00000000..c76e5ae8 --- /dev/null +++ b/assets/js/burger.js @@ -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 }; diff --git a/assets/js/cable.js b/assets/js/cable.js new file mode 100644 index 00000000..03ed74e2 --- /dev/null +++ b/assets/js/cable.js @@ -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 }; diff --git a/assets/js/captcha.js b/assets/js/captcha.js new file mode 100644 index 00000000..e9884cb5 --- /dev/null +++ b/assets/js/captcha.js @@ -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', '

Failed to fetch challenge from server!

'); + 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 }; diff --git a/assets/js/comment.js b/assets/js/comment.js new file mode 100644 index 00000000..72c6fcf5 --- /dev/null +++ b/assets/js/comment.js @@ -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 = '
Comment failed to load!
'; + + 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 }; diff --git a/assets/js/communications/comment.js b/assets/js/communications/comment.js new file mode 100644 index 00000000..a4661c61 --- /dev/null +++ b/assets/js/communications/comment.js @@ -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 }; diff --git a/assets/js/communications/post.js b/assets/js/communications/post.js new file mode 100644 index 00000000..d2172220 --- /dev/null +++ b/assets/js/communications/post.js @@ -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 }; diff --git a/assets/js/duplicate_reports.js b/assets/js/duplicate_reports.js new file mode 100644 index 00000000..a6794ec4 --- /dev/null +++ b/assets/js/duplicate_reports.js @@ -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 }; diff --git a/assets/js/fingerprint.js b/assets/js/fingerprint.js new file mode 100644 index 00000000..aac85635 --- /dev/null +++ b/assets/js/fingerprint.js @@ -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 }; diff --git a/assets/js/galleries.js b/assets/js/galleries.js new file mode 100644 index 00000000..6cf97865 --- /dev/null +++ b/assets/js/galleries.js @@ -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()); + }); +} diff --git a/assets/js/image_expansion.js b/assets/js/image_expansion.js new file mode 100644 index 00000000..5b639976 --- /dev/null +++ b/assets/js/image_expansion.js @@ -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', + `` + ); + } + else if (imageFormat === 'webm') { + elem.insertAdjacentHTML('afterbegin', + `` + ); + 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 = ``; + } + else if (scaled === 'partscaled') { + image = ``; + } + else { + image = ``; + } + 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 }; diff --git a/assets/js/imagesclientside.js b/assets/js/imagesclientside.js new file mode 100644 index 00000000..7dc3fb31 --- /dev/null +++ b/assets/js/imagesclientside.js @@ -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] (Complex Filter)'); } +function spoilerThumbComplex(img) { spoilerThumb(img, window.booru.hiddenTag, '(Complex Filter)'); } + +function filterBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, which is hidden by `); } +function spoilerBlockSimple(img, tagsHit) { spoilerBlock(img, tagsHit[0].spoiler_image_uri || window.booru.hiddenTag, `This image is tagged ${escapeHtml(tagsHit[0].name)}, 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 }; diff --git a/assets/js/interactions.js b/assets/js/interactions.js new file mode 100644 index 00000000..fed1ea66 --- /dev/null +++ b/assets/js/interactions.js @@ -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 }; diff --git a/assets/js/match_query.js b/assets/js/match_query.js new file mode 100644 index 00000000..1d3d9c93 --- /dev/null +++ b/assets/js/match_query.js @@ -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; diff --git a/assets/js/misc.js b/assets/js/misc.js new file mode 100644 index 00000000..39109504 --- /dev/null +++ b/assets/js/misc.js @@ -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 }; diff --git a/assets/js/notifications.js b/assets/js/notifications.js new file mode 100644 index 00000000..7105b9ca --- /dev/null +++ b/assets/js/notifications.js @@ -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 }; diff --git a/assets/js/poll.js b/assets/js/poll.js new file mode 100644 index 00000000..6786412a --- /dev/null +++ b/assets/js/poll.js @@ -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 }; diff --git a/assets/js/preview.js b/assets/js/preview.js new file mode 100644 index 00000000..c071591f --- /dev/null +++ b/assets/js/preview.js @@ -0,0 +1,88 @@ +/** + * Textile previews (posts, comments, messages) + */ + +import { fetchJson } from './utils/requests'; +import { filterNode } from './imagesclientside'; + +function handleError(response) { + const errorMessage = '
Preview failed to load!
'; + + 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 }; diff --git a/assets/js/quick-tag.js b/assets/js/quick-tag.js new file mode 100644 index 00000000..56fc1540 --- /dev/null +++ b/assets/js/quick-tag.js @@ -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 }; diff --git a/assets/js/resizablemedia.js b/assets/js/resizablemedia.js new file mode 100644 index 00000000..4031cb75 --- /dev/null +++ b/assets/js/resizablemedia.js @@ -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 }; diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 00000000..864ea231 --- /dev/null +++ b/assets/js/search.js @@ -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 }; diff --git a/assets/js/settings.js b/assets/js/settings.js new file mode 100644 index 00000000..99fa8719 --- /dev/null +++ b/assets/js/settings.js @@ -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; + }); + +} diff --git a/assets/js/shortcuts.js b/assets/js/shortcuts.js new file mode 100644 index 00000000..4edb6e2c --- /dev/null +++ b/assets/js/shortcuts.js @@ -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 }; diff --git a/assets/js/staffhider.js b/assets/js/staffhider.js new file mode 100644 index 00000000..4f3d2339 --- /dev/null +++ b/assets/js/staffhider.js @@ -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 }; diff --git a/assets/js/tags.js b/assets/js/tags.js new file mode 100644 index 00000000..1a97c196 --- /dev/null +++ b/assets/js/tags.js @@ -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 }; diff --git a/assets/js/tagsinput.js b/assets/js/tagsinput.js new file mode 100644 index 00000000..019433ae --- /dev/null +++ b/assets/js/tagsinput.js @@ -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 = `${escapeHtml(name)} x`; + 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 }; diff --git a/assets/js/tagsmisc.js b/assets/js/tagsmisc.js new file mode 100644 index 00000000..78a7c1e9 --- /dev/null +++ b/assets/js/tagsmisc.js @@ -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 }; diff --git a/assets/js/textiletoolbar.js b/assets/js/textiletoolbar.js new file mode 100644 index 00000000..ebd97085 --- /dev/null +++ b/assets/js/textiletoolbar.js @@ -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 }; diff --git a/assets/js/timeago.js b/assets/js/timeago.js new file mode 100644 index 00000000..f3694d6f --- /dev/null +++ b/assets/js/timeago.js @@ -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; diff --git a/assets/js/ujs.js b/assets/js/ujs.js new file mode 100644 index 00000000..a3ae9e3c --- /dev/null +++ b/assets/js/ujs.js @@ -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 +}); diff --git a/assets/js/upload.js b/assets/js/upload.js new file mode 100644 index 00000000..d3e98a92 --- /dev/null +++ b/assets/js/upload.js @@ -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 }; diff --git a/assets/js/utils/array.js b/assets/js/utils/array.js new file mode 100644 index 00000000..61f1edf1 --- /dev/null +++ b/assets/js/utils/array.js @@ -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 }; diff --git a/assets/js/utils/dom.js b/assets/js/utils/dom.js new file mode 100644 index 00000000..df128d23 --- /dev/null +++ b/assets/js/utils/dom.js @@ -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(/ el.nodeType === Node.TEXT_NODE)[0]; +} + +export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, findFirstTextNode }; diff --git a/assets/js/utils/draggable.js b/assets/js/utils/draggable.js new file mode 100644 index 00000000..9ac06d9c --- /dev/null +++ b/assets/js/utils/draggable.js @@ -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)); +} diff --git a/assets/js/utils/events.js b/assets/js/utils/events.js new file mode 100644 index 00000000..f5685053 --- /dev/null +++ b/assets/js/utils/events.js @@ -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; + } + }); +} diff --git a/assets/js/utils/image.js b/assets/js/utils/image.js new file mode 100644 index 00000000..fec1bd2b --- /dev/null +++ b/assets/js/utils/image.js @@ -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 = ` + + + `; + 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 }; diff --git a/assets/js/utils/requests.js b/assets/js/utils/requests.js new file mode 100644 index 00000000..3981dd68 --- /dev/null +++ b/assets/js/utils/requests.js @@ -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 }; diff --git a/assets/js/utils/store.js b/assets/js/utils/store.js new file mode 100644 index 00000000..8681c0cf --- /dev/null +++ b/assets/js/utils/store.js @@ -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; + }, + +}; diff --git a/assets/js/utils/tag.js b/assets/js/utils/tag.js new file mode 100644 index 00000000..699b0e57 --- /dev/null +++ b/assets/js/utils/tag.js @@ -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 += `, ${extras}`; + } + + return list; +} + +export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags }; diff --git a/assets/js/vendor/closest.polyfill.js b/assets/js/vendor/closest.polyfill.js new file mode 100644 index 00000000..2c60a6d5 --- /dev/null +++ b/assets/js/vendor/closest.polyfill.js @@ -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; + }; +} diff --git a/assets/js/vendor/customevent.polyfill.js b/assets/js/vendor/customevent.polyfill.js new file mode 100644 index 00000000..7d13f9cf --- /dev/null +++ b/assets/js/vendor/customevent.polyfill.js @@ -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; +})(); diff --git a/assets/js/vendor/es6.polyfill.js b/assets/js/vendor/es6.polyfill.js new file mode 100644 index 00000000..51c1c423 --- /dev/null +++ b/assets/js/vendor/es6.polyfill.js @@ -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; + }; +} diff --git a/assets/js/vendor/fetch.polyfill.js b/assets/js/vendor/fetch.polyfill.js new file mode 100644 index 00000000..456f8c1f --- /dev/null +++ b/assets/js/vendor/fetch.polyfill.js @@ -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 + } + }; + } + }); + }; +} diff --git a/assets/js/vendor/promise.polyfill.js b/assets/js/vendor/promise.polyfill.js new file mode 100644 index 00000000..c1cfac74 --- /dev/null +++ b/assets/js/vendor/promise.polyfill.js @@ -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); diff --git a/assets/js/when-ready.js b/assets/js/when-ready.js new file mode 100644 index 00000000..4f52a875 --- /dev/null +++ b/assets/js/when-ready.js @@ -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(); + +});