This commit is contained in:
Luna D. 2024-07-03 22:54:14 +02:00
parent 4ae468142d
commit 33ede2722b
No known key found for this signature in database
GPG key ID: 4B1C63448394F688
76 changed files with 1394 additions and 1119 deletions

View file

@ -2,6 +2,13 @@ tabWidth: 2
useTabs: false useTabs: false
printWidth: 120 printWidth: 120
semi: true semi: true
singleQuote: false singleQuote: true
bracketSpacing: true bracketSpacing: true
endOfLine: lf endOfLine: lf
quoteProps: as-needed
trailingComma: all
arrowParens: always
overrides:
- files: "*.css"
options:
singleQuote: false

View file

@ -1,9 +1,11 @@
import tsEslint from 'typescript-eslint'; import tsEslint from 'typescript-eslint';
import vitestPlugin from 'eslint-plugin-vitest'; import vitestPlugin from 'eslint-plugin-vitest';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals'; import globals from 'globals';
export default tsEslint.config( export default tsEslint.config(
...tsEslint.configs.recommended, ...tsEslint.configs.recommended,
eslintPluginPrettierRecommended,
{ {
name: 'PhilomenaConfig', name: 'PhilomenaConfig',
files: ['**/*.js', '**/*.ts'], files: ['**/*.js', '**/*.ts'],
@ -12,22 +14,20 @@ export default tsEslint.config(
sourceType: 'module', sourceType: 'module',
parserOptions: { parserOptions: {
ecmaVersion: 6, ecmaVersion: 6,
sourceType: 'module' sourceType: 'module',
}, },
globals: { globals: {
...globals.browser ...globals.browser,
} },
}, },
rules: { rules: {
'accessor-pairs': 2, 'accessor-pairs': 2,
'array-bracket-spacing': 0, 'array-bracket-spacing': 0,
'array-callback-return': 2, 'array-callback-return': 2,
'arrow-body-style': 0, 'arrow-body-style': 0,
'arrow-parens': [2, 'as-needed'],
'arrow-spacing': 2, 'arrow-spacing': 2,
'block-scoped-var': 2, 'block-scoped-var': 2,
'block-spacing': 2, 'block-spacing': 2,
'brace-style': [2, 'stroustrup', {allowSingleLine: true}],
'callback-return': 0, 'callback-return': 0,
camelcase: [2, { allow: ['camo_url', 'spoiler_image_uri', 'image_ids'] }], camelcase: [2, { allow: ['camo_url', 'spoiler_image_uri', 'image_ids'] }],
'class-methods-use-this': 0, 'class-methods-use-this': 0,
@ -56,7 +56,6 @@ export default tsEslint.config(
'id-blacklist': 0, 'id-blacklist': 0,
'id-length': 0, 'id-length': 0,
'id-match': 2, 'id-match': 2,
indent: [2, 2, {SwitchCase: 1, VariableDeclarator: {var: 2, let: 2, const: 3}}],
'init-declarations': 0, 'init-declarations': 0,
'jsx-quotes': 0, 'jsx-quotes': 0,
'key-spacing': 0, 'key-spacing': 0,
@ -222,7 +221,6 @@ export default tsEslint.config(
'prefer-spread': 0, 'prefer-spread': 0,
'prefer-template': 2, 'prefer-template': 2,
'quote-props': [2, 'as-needed'], 'quote-props': [2, 'as-needed'],
quotes: [2, 'single'],
radix: 2, radix: 2,
'require-jsdoc': 0, 'require-jsdoc': 0,
'require-yield': 2, 'require-yield': 2,
@ -233,7 +231,6 @@ export default tsEslint.config(
'sort-keys': 0, 'sort-keys': 0,
'sort-vars': 0, 'sort-vars': 0,
'space-before-blocks': [2, 'always'], 'space-before-blocks': [2, 'always'],
'space-before-function-paren': [2, 'never'],
'space-in-parens': [2, 'never'], 'space-in-parens': [2, 'never'],
'space-infix-ops': 2, 'space-infix-ops': 2,
'space-unary-ops': [2, { words: true, nonwords: false }], 'space-unary-ops': [2, { words: true, nonwords: false }],
@ -251,18 +248,15 @@ export default tsEslint.config(
'yield-star-spacing': 2, 'yield-star-spacing': 2,
yoda: [2, 'never'], yoda: [2, 'never'],
}, },
ignores: [ ignores: ['js/vendor/*', 'vite.config.ts'],
'js/vendor/*',
'vite.config.ts'
]
}, },
{ {
files: ['**/*.js'], files: ['**/*.js'],
rules: { rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-expressions': 'off', '@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unused-vars': 'off' '@typescript-eslint/no-unused-vars': 'off',
} },
}, },
{ {
files: ['**/*.ts'], files: ['**/*.ts'],
@ -271,15 +265,18 @@ export default tsEslint.config(
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'no-redeclare': 'off', 'no-redeclare': 'off',
'no-shadow': 'off', 'no-shadow': 'off',
'@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*'}], '@typescript-eslint/no-unused-vars': [
2,
{ vars: 'all', args: 'after-used', varsIgnorePattern: '^_.*', argsIgnorePattern: '^_.*' },
],
'@typescript-eslint/no-redeclare': 2, '@typescript-eslint/no-redeclare': 2,
'@typescript-eslint/no-shadow': 2 '@typescript-eslint/no-shadow': 2,
} },
}, },
{ {
files: ['**/*.spec.ts', '**/test/*.ts'], files: ['**/*.spec.ts', '**/test/*.ts'],
plugins: { plugins: {
vitest: vitestPlugin vitest: vitestPlugin,
}, },
rules: { rules: {
...vitestPlugin.configs.recommended.rules, ...vitestPlugin.configs.recommended.rules,
@ -287,7 +284,7 @@ export default tsEslint.config(
'no-undefined': 'off', 'no-undefined': 'off',
'no-unused-expressions': 0, 'no-unused-expressions': 0,
'vitest/valid-expect': 0, 'vitest/valid-expect': 0,
'@typescript-eslint/no-unused-expressions': 0 '@typescript-eslint/no-unused-expressions': 0,
} },
} },
); );

View file

@ -90,7 +90,7 @@ describe('filterNode', () => {
element, element,
assertNotNull($<HTMLDivElement>('.image-filtered', element)), assertNotNull($<HTMLDivElement>('.image-filtered', element)),
assertNotNull($<HTMLDivElement>('.image-show', element)), assertNotNull($<HTMLDivElement>('.image-show', element)),
assertNotNull($<HTMLSpanElement>('.filter-explanation', element)) assertNotNull($<HTMLSpanElement>('.filter-explanation', element)),
]; ];
} }
@ -150,7 +150,6 @@ describe('filterNode', () => {
expect(filterExplanation).toContainHTML('complex tag expression'); expect(filterExplanation).toContainHTML('complex tag expression');
expect(window.booru.imagesWithDownvotingDisabled).toContain('1'); expect(window.booru.imagesWithDownvotingDisabled).toContain('1');
}); });
}); });
describe('initImagesClientside', () => { describe('initImagesClientside', () => {

View file

@ -5,7 +5,9 @@ import { fireEvent } from '@testing-library/dom';
describe('Input duplicator functionality', () => { describe('Input duplicator functionality', () => {
beforeEach(() => { beforeEach(() => {
document.documentElement.insertAdjacentHTML('beforeend', `<form action="/"> document.documentElement.insertAdjacentHTML(
'beforeend',
`<form action="/">
<div class="js-max-input-count">3</div> <div class="js-max-input-count">3</div>
<div class="js-input-source"> <div class="js-input-source">
<input id="0" name="0" class="js-input" type="text"/> <input id="0" name="0" class="js-input" type="text"/>
@ -16,7 +18,8 @@ describe('Input duplicator functionality', () => {
<div class="js-button-container"> <div class="js-button-container">
<button type="button" class="js-add-input">Add input</button> <button type="button" class="js-add-input">Add input</button>
</div> </div>
</form>`); </form>`,
);
}); });
afterEach(() => { afterEach(() => {

View file

@ -29,7 +29,7 @@ describe('Remote utilities', () => {
} }
describe('a[data-remote]', () => { describe('a[data-remote]', () => {
const submitA = ({ setMethod }: { setMethod: boolean; }) => { const submitA = ({ setMethod }: { setMethod: boolean }) => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = mockEndpoint; a.href = mockEndpoint;
a.dataset.remote = 'remote'; a.dataset.remote = 'remote';
@ -51,8 +51,8 @@ describe('Remote utilities', () => {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest' 'x-requested-with': 'XMLHttpRequest',
} },
}); });
}); });
@ -64,15 +64,16 @@ describe('Remote utilities', () => {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest' 'x-requested-with': 'XMLHttpRequest',
} },
}); });
}); });
it('should emit fetchcomplete event', () => new Promise<void>(resolve => { it('should emit fetchcomplete event', () =>
new Promise<void>((resolve) => {
let a: HTMLAnchorElement | null = null; let a: HTMLAnchorElement | null = null;
addOneShotEventListener('fetchcomplete', event => { addOneShotEventListener('fetchcomplete', (event) => {
expect(event.target).toBe(a); expect(event.target).toBe(a);
resolve(); resolve();
}); });
@ -93,8 +94,9 @@ describe('Remote utilities', () => {
return a; return a;
}; };
it('should submit a form with the given action', () => new Promise<void>(resolve => { it('should submit a form with the given action', () =>
addOneShotEventListener('submit', event => { new Promise<void>((resolve) => {
addOneShotEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
const target = assertType(event.target, HTMLFormElement); const target = assertType(event.target, HTMLFormElement);
@ -167,7 +169,7 @@ describe('Remote utilities', () => {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest' 'x-requested-with': 'XMLHttpRequest',
}, },
body: new FormData(), body: new FormData(),
}); });
@ -183,16 +185,17 @@ describe('Remote utilities', () => {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest' 'x-requested-with': 'XMLHttpRequest',
}, },
body: new FormData(), body: new FormData(),
}); });
}); });
it('should emit fetchcomplete event', () => new Promise<void>(resolve => { it('should emit fetchcomplete event', () =>
new Promise<void>((resolve) => {
let form: HTMLFormElement | null = null; let form: HTMLFormElement | null = null;
addOneShotEventListener('fetchcomplete', event => { addOneShotEventListener('fetchcomplete', (event) => {
expect(event.target).toBe(form); expect(event.target).toBe(form);
resolve(); resolve();
}); });
@ -211,7 +214,7 @@ describe('Remote utilities', () => {
describe('Form utilities', () => { describe('Form utilities', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(1); cb(1);
return 1; return 1;
}); });
@ -257,7 +260,7 @@ describe('Form utilities', () => {
// jsdom has no implementation for HTMLFormElement.prototype.submit // jsdom has no implementation for HTMLFormElement.prototype.submit
// and will return an error if the event's default isn't prevented // and will return an error if the event's default isn't prevented
form.addEventListener('submit', event => event.preventDefault()); form.addEventListener('submit', (event) => event.preventDefault());
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'submit'; button.type = 'submit';

View file

@ -33,8 +33,12 @@ describe('Image upload form', () => {
const mockPngPath = join(__dirname, 'upload-test.png'); const mockPngPath = join(__dirname, 'upload-test.png');
const mockWebmPath = join(__dirname, 'upload-test.webm'); const mockWebmPath = join(__dirname, 'upload-test.webm');
mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { type: 'image/png' }); mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', {
mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { type: 'video/webm' }); type: 'image/png',
});
mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', {
type: 'video/webm',
});
}); });
beforeAll(() => { beforeAll(() => {
@ -47,7 +51,6 @@ describe('Image upload form', () => {
fixEventListeners(window); fixEventListeners(window);
let form: HTMLFormElement; let form: HTMLFormElement;
let imgPreviews: HTMLDivElement; let imgPreviews: HTMLDivElement;
let fileField: HTMLInputElement; let fileField: HTMLInputElement;
@ -63,7 +66,9 @@ describe('Image upload form', () => {
}; };
beforeEach(() => { beforeEach(() => {
document.documentElement.insertAdjacentHTML('beforeend', ` document.documentElement.insertAdjacentHTML(
'beforeend',
`
<form action="/images"> <form action="/images">
<div id="js-image-upload-previews"></div> <div id="js-image-upload-previews"></div>
<input id="image_image" name="image[image]" type="file" class="js-scraper" /> <input id="image_image" name="image[image]" type="file" class="js-scraper" />
@ -75,7 +80,8 @@ describe('Image upload form', () => {
<textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea> <textarea id="image_tag_input" name="image[tag_input]" class="js-image-tags-input"></textarea>
<textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea> <textarea id="image_description" name="image[description]" class="js-image-descr-input"></textarea>
</form> </form>
`); `,
);
form = assertNotNull($<HTMLFormElement>('form')); form = assertNotNull($<HTMLFormElement>('form'));
imgPreviews = assertNotNull($<HTMLDivElement>('#js-image-upload-previews')); imgPreviews = assertNotNull($<HTMLDivElement>('#js-image-upload-previews'));
@ -131,8 +137,8 @@ describe('Image upload form', () => {
const failedUnloadEvent = new Event('beforeunload', { cancelable: true }); const failedUnloadEvent = new Event('beforeunload', { cancelable: true });
expect(fireEvent(window, failedUnloadEvent)).toBe(false); expect(fireEvent(window, failedUnloadEvent)).toBe(false);
await new Promise<void>(resolve => { await new Promise<void>((resolve) => {
form.addEventListener('submit', event => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
resolve(); resolve();
}); });
@ -147,7 +153,7 @@ describe('Image upload form', () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 })); fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 }));
fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } }); fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' } });
await new Promise<void>(resolve => { await new Promise<void>((resolve) => {
tagsEl.addEventListener('addtag', (event: Event) => { tagsEl.addEventListener('addtag', (event: Event) => {
expect((event as CustomEvent).detail).toEqual({ name: 'artist:test' }); expect((event as CustomEvent).detail).toEqual({ name: 'artist:test' });
resolve(); resolve();

View file

@ -52,15 +52,16 @@ function applySelectedValue(selection) {
} }
function changeSelected(firstOrLast, current, sibling) { function changeSelected(firstOrLast, current, sibling) {
if (current && sibling) { // if the currently selected item has a sibling, move selection to it if (current && sibling) {
// if the currently selected item has a sibling, move selection to it
current.classList.remove('autocomplete__item--selected'); current.classList.remove('autocomplete__item--selected');
sibling.classList.add('autocomplete__item--selected'); sibling.classList.add('autocomplete__item--selected');
} } else if (current) {
else if (current) { // if the next keypress will take the user outside the list, restore the unautocompleted term // if the next keypress will take the user outside the list, restore the unautocompleted term
restoreOriginalValue(); restoreOriginalValue();
removeSelected(); removeSelected();
} } else if (firstOrLast) {
else if (firstOrLast) { // if no item in the list is selected, select the first or last // if no item in the list is selected, select the first or last
firstOrLast.classList.add('autocomplete__item--selected'); firstOrLast.classList.add('autocomplete__item--selected');
} }
} }
@ -82,7 +83,8 @@ function keydownHandler(event) {
if (selected && event.keyCode === 13) event.preventDefault(); // Enter if (selected && event.keyCode === 13) event.preventDefault(); // Enter
// Close autocompletion popup when text cursor is outside current tag // Close autocompletion popup when text cursor is outside current tag
if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) { // ArrowLeft || ArrowRight if (selectedTerm && firstItem && (event.keyCode === 37 || event.keyCode === 39)) {
// ArrowLeft || ArrowRight
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (isSelectionOutsideCurrentTerm()) removeParent(); if (isSelectionOutsideCurrentTerm()) removeParent();
}); });
@ -92,7 +94,8 @@ function keydownHandler(event) {
if (event.keyCode === 38) changeSelected(lastItem, selected, selected && selected.previousSibling); // ArrowUp 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 === 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 === 13 || event.keyCode === 27 || event.keyCode === 188) removeParent(); // Enter || Esc || Comma
if (event.keyCode === 38 || event.keyCode === 40) { // ArrowUp || ArrowDown if (event.keyCode === 38 || event.keyCode === 40) {
// ArrowUp || ArrowDown
const newSelected = document.querySelector('.autocomplete__item--selected'); const newSelected = document.querySelector('.autocomplete__item--selected');
if (newSelected) applySelectedValue(newSelected.dataset.value); if (newSelected) applySelectedValue(newSelected.dataset.value);
event.preventDefault(); event.preventDefault();
@ -123,8 +126,8 @@ function createItem(list, suggestion) {
type: 'click', type: 'click',
label: suggestion.label, label: suggestion.label,
value: suggestion.value, value: suggestion.value,
} },
}) }),
); );
}); });
@ -136,7 +139,7 @@ function createList(suggestions) {
list = document.createElement('ul'); list = document.createElement('ul');
list.className = 'autocomplete__list'; list.className = 'autocomplete__list';
suggestions.forEach(suggestion => createItem(list, suggestion)); suggestions.forEach((suggestion) => createItem(list, suggestion));
parent.appendChild(list); parent.appendChild(list);
} }
@ -173,7 +176,7 @@ function showAutocomplete(suggestions, fetchedTerm, targetInput) {
function getSuggestions(term) { function getSuggestions(term) {
// In case source URL was not given at all, do not try sending the request. // In case source URL was not given at all, do not try sending the request.
if (!inputField.dataset.acSource) return []; if (!inputField.dataset.acSource) return [];
return fetch(`${inputField.dataset.acSource}${term}`).then(response => response.json()); return fetch(`${inputField.dataset.acSource}${term}`).then((response) => response.json());
} }
function getSelectedTerm() { function getSelectedTerm() {
@ -193,8 +196,7 @@ function toggleSearchAutocomplete() {
for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) { for (const searchField of document.querySelectorAll('input[data-ac-mode=search]')) {
if (enable) { if (enable) {
searchField.autocomplete = 'off'; searchField.autocomplete = 'off';
} } else {
else {
searchField.removeAttribute('data-ac'); searchField.removeAttribute('data-ac');
searchField.autocomplete = 'on'; searchField.autocomplete = 'on';
} }
@ -210,7 +212,7 @@ function listenAutocomplete() {
document.addEventListener('focusin', fetchLocalAutocomplete); document.addEventListener('focusin', fetchLocalAutocomplete);
document.addEventListener('input', event => { document.addEventListener('input', (event) => {
removeParent(); removeParent();
fetchLocalAutocomplete(event); fetchLocalAutocomplete(event);
window.clearTimeout(timeout); window.clearTimeout(timeout);
@ -230,12 +232,13 @@ function listenAutocomplete() {
} }
originalTerm = selectedTerm[1].toLowerCase(); originalTerm = selectedTerm[1].toLowerCase();
} } else {
else {
originalTerm = `${inputField.value}`.toLowerCase(); originalTerm = `${inputField.value}`.toLowerCase();
} }
const suggestions = localAc.topK(originalTerm, suggestionsCount).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); const suggestions = localAc
.topK(originalTerm, suggestionsCount)
.map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name }));
if (suggestions.length) { if (suggestions.length) {
return showAutocomplete(suggestions, originalTerm, event.target); return showAutocomplete(suggestions, originalTerm, event.target);
@ -250,13 +253,12 @@ function listenAutocomplete() {
const fetchedTerm = inputField.value; const fetchedTerm = inputField.value;
const { ac, acMinLength, acSource } = inputField.dataset; const { ac, acMinLength, acSource } = inputField.dataset;
if (ac && acSource && (fetchedTerm.length >= acMinLength)) { if (ac && acSource && fetchedTerm.length >= acMinLength) {
if (cache[fetchedTerm]) { if (cache[fetchedTerm]) {
showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target); showAutocomplete(cache[fetchedTerm], fetchedTerm, event.target);
} } else {
else {
// inputField could get overwritten while the suggestions are being fetched - use event.target // inputField could get overwritten while the suggestions are being fetched - use event.target
getSuggestions(fetchedTerm).then(suggestions => { getSuggestions(fetchedTerm).then((suggestions) => {
if (fetchedTerm === event.target.value) { if (fetchedTerm === event.target.value) {
showAutocomplete(suggestions, fetchedTerm, event.target); showAutocomplete(suggestions, fetchedTerm, event.target);
} }
@ -267,7 +269,7 @@ function listenAutocomplete() {
}); });
// If there's a click outside the inputField, remove autocomplete // If there's a click outside the inputField, remove autocomplete
document.addEventListener('click', event => { document.addEventListener('click', (event) => {
if (event.target && event.target !== inputField) removeParent(); if (event.target && event.target !== inputField) removeParent();
if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent(); if (event.target === inputField && isSearchField() && isSelectionOutsideCurrentTerm()) removeParent();
}); });
@ -281,8 +283,10 @@ function listenAutocomplete() {
fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' }) fetch(`/autocomplete/compiled?vsn=2&key=${cacheKey}`, { credentials: 'omit', cache: 'force-cache' })
.then(handleError) .then(handleError)
.then(resp => resp.arrayBuffer()) .then((resp) => resp.arrayBuffer())
.then(buf => localAc = new LocalAutocompleter(buf)); .then((buf) => {
localAc = new LocalAutocompleter(buf);
});
} }
} }

View file

@ -23,11 +23,11 @@ function persistTag(tagData) {
*/ */
function isStale(tag) { function isStale(tag) {
const now = new Date().getTime() / 1000; const now = new Date().getTime() / 1000;
return tag.fetchedAt === null || tag.fetchedAt < (now - 604800); return tag.fetchedAt === null || tag.fetchedAt < now - 604800;
} }
function clearTags() { function clearTags() {
Object.keys(localStorage).forEach(key => { Object.keys(localStorage).forEach((key) => {
if (key.substring(0, 9) === 'bor_tags_') { if (key.substring(0, 9) === 'bor_tags_') {
store.remove(key); store.remove(key);
} }
@ -40,11 +40,13 @@ function clearTags() {
*/ */
function isValidStoredTag(value) { function isValidStoredTag(value) {
if (value !== null && 'id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) { if (value !== null && 'id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) {
return typeof value.id === 'number' return (
&& typeof value.name === 'string' typeof value.id === 'number' &&
&& typeof value.images === 'number' typeof value.name === 'string' &&
&& (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string') typeof value.images === 'number' &&
&& (value.fetchedAt === null || typeof value.fetchedAt === 'number'); (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string') &&
(value.fetchedAt === null || typeof value.fetchedAt === 'number')
);
} }
return false; return false;
@ -82,8 +84,8 @@ function fetchAndPersistTags(tagIds) {
const remaining = tagIds.slice(41); const remaining = tagIds.slice(41);
fetch(`/fetch/tags?ids[]=${ids.join('&ids[]=')}`) fetch(`/fetch/tags?ids[]=${ids.join('&ids[]=')}`)
.then(response => response.json()) .then((response) => response.json())
.then(data => data.tags.forEach(tag => persistTag(tag))) .then((data) => data.tags.forEach((tag) => persistTag(tag)))
.then(() => fetchAndPersistTags(remaining)); .then(() => fetchAndPersistTags(remaining));
} }
@ -94,7 +96,7 @@ function fetchAndPersistTags(tagIds) {
function fetchNewOrStaleTags(tagIds) { function fetchNewOrStaleTags(tagIds) {
const fetchIds = []; const fetchIds = [];
tagIds.forEach(t => { tagIds.forEach((t) => {
const stored = store.get(`bor_tags_${t}`); const stored = store.get(`bor_tags_${t}`);
if (!stored || isStale(stored)) { if (!stored || isStale(stored)) {
fetchIds.push(t); fetchIds.push(t);
@ -112,17 +114,18 @@ function verifyTagsVersion(latest) {
} }
function initializeFilters() { function initializeFilters() {
const tags = window.booru.spoileredTagList const tags = window.booru.spoileredTagList.concat(window.booru.hiddenTagList).filter((a, b, c) => c.indexOf(a) === b);
.concat(window.booru.hiddenTagList)
.filter((a, b, c) => c.indexOf(a) === b);
verifyTagsVersion(window.booru.tagsVersion); verifyTagsVersion(window.booru.tagsVersion);
fetchNewOrStaleTags(tags); fetchNewOrStaleTags(tags);
} }
function unmarshal(data) { function unmarshal(data) {
try { return JSON.parse(data); } try {
catch { return data; } return JSON.parse(data);
} catch {
return data;
}
} }
function loadBooruData() { function loadBooruData() {

View file

@ -11,40 +11,72 @@ import { addTag } from './tagsinput';
// Event types and any qualifying conditions - return true to not run action // Event types and any qualifying conditions - return true to not run action
const types = { const types = {
click(event) { return event.button !== 0; /* Left-click only */ }, click(event) {
return event.button !== 0; /* Left-click only */
},
change() { /* No qualifier */ }, change() {
/* No qualifier */
},
fetchcomplete() { /* No qualifier */ }, fetchcomplete() {
/* No qualifier */
},
}; };
const actions = { const actions = {
hide(data) { selectorCb(data.base, data.value, el => el.classList.add('hidden')); }, hide(data) {
selectorCb(data.base, data.value, (el) => el.classList.add('hidden'));
},
tabHide(data) { selectorCbChildren(data.base, data.value, el => el.classList.add('hidden')); }, tabHide(data) {
selectorCbChildren(data.base, data.value, (el) => el.classList.add('hidden'));
},
show(data) { selectorCb(data.base, data.value, el => el.classList.remove('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')); }, toggle(data) {
selectorCb(data.base, data.value, (el) => el.classList.toggle('hidden'));
},
submit(data) { selectorCb(data.base, data.value, el => el.submit()); }, submit(data) {
selectorCb(data.base, data.value, (el) => el.submit());
},
disable(data) { selectorCb(data.base, data.value, el => el.disabled = true); }, disable(data) {
selectorCb(data.base, data.value, (el) => {
el.disabled = true;
});
},
copy(data) { copy(data) {
document.querySelector(data.value).select(); document.querySelector(data.value).select();
document.execCommand('copy'); document.execCommand('copy');
}, },
inputvalue(data) { document.querySelector(data.value).value = data.el.dataset.setValue; }, inputvalue(data) {
document.querySelector(data.value).value = data.el.dataset.setValue;
},
selectvalue(data) { document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue; }, selectvalue(data) {
document.querySelector(data.value).value = data.el.querySelector(':checked').dataset.setValue;
},
checkall(data) { $$(`${data.value} input[type=checkbox]`).forEach(c => { c.checked = !c.checked; }); }, checkall(data) {
$$(`${data.value} input[type=checkbox]`).forEach((c) => {
c.checked = !c.checked;
});
},
focus(data) { document.querySelector(data.value).focus(); }, focus(data) {
document.querySelector(data.value).focus();
},
preventdefault() { /* The existence of this entry is enough */ }, preventdefault() {
/* The existence of this entry is enough */
},
addtag(data) { addtag(data) {
addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName); addTag(document.querySelector(data.el.closest('[data-target]').dataset.target), data.el.dataset.tagName);
@ -70,16 +102,22 @@ const actions = {
if (loadTab && !newTab.dataset.loaded) { if (loadTab && !newTab.dataset.loaded) {
fetchHtml(loadTab) fetchHtml(loadTab)
.then(handleError) .then(handleError)
.then(response => response.text()) .then((response) => response.text())
.then(response => newTab.innerHTML = response) .then((response) => {
.then(() => newTab.dataset.loaded = true) newTab.innerHTML = response;
.catch(() => newTab.textContent = 'Error!'); })
.then(() => {
newTab.dataset.loaded = true;
})
.catch(() => {
newTab.textContent = 'Error!';
});
} }
}, },
unfilter(data) { showBlock(data.el.closest('.image-show-container')); }, unfilter(data) {
showBlock(data.el.closest('.image-show-container'));
},
}; };
// Use this function to apply a callback to elements matching the selectors // Use this function to apply a callback to elements matching the selectors
@ -100,7 +138,6 @@ function selectorCbChildren(base = document, selector, cb) {
function matchAttributes(event) { function matchAttributes(event) {
if (!types[event.type](event)) { if (!types[event.type](event)) {
for (const action in actions) { for (const action in actions) {
const attr = `data-${event.type}-${action.toLowerCase()}`, const attr = `data-${event.type}-${action.toLowerCase()}`,
el = event.target && event.target.closest(`[${attr}]`), el = event.target && event.target.closest(`[${attr}]`),
value = el && el.getAttribute(attr); value = el && el.getAttribute(attr);
@ -109,7 +146,6 @@ function matchAttributes(event) {
// Return true if you don't want to preventDefault // Return true if you don't want to preventDefault
actions[action]({ attr, el, value }) || event.preventDefault(); actions[action]({ attr, el, value }) || event.preventDefault();
} }
} }
} }
} }

View file

@ -40,7 +40,7 @@ function copyUserLinksTo(burger: HTMLElement) {
} }
}; };
$$<HTMLElement>('.js-burger-links').forEach(container => copy(container.children)); $$<HTMLElement>('.js-burger-links').forEach((container) => copy(container.children));
} }
export function setupBurgerMenu() { export function setupBurgerMenu() {
@ -53,14 +53,13 @@ export function setupBurgerMenu() {
copyUserLinksTo(burger); copyUserLinksTo(burger);
toggle.addEventListener('click', event => { toggle.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
if (content.classList.contains('open')) { if (content.classList.contains('open')) {
close(burger, content, body, root); close(burger, content, body, root);
} } else {
else {
open(burger, content, body, root); open(burger, content, body, root);
} }
}); });

View file

@ -27,16 +27,14 @@ function commentPosted(response) {
commentEditForm.reset(); commentEditForm.reset();
if (requestOk) { if (requestOk) {
response.text().then(text => { response.text().then((text) => {
if (text.includes('<div class="flash flash--warning">')) { if (text.includes('<div class="flash flash--warning">')) {
window.location.reload(); window.location.reload();
} } else {
else {
displayComments(container, text); displayComments(container, text);
} }
}); });
} } else {
else {
window.location.reload(); window.location.reload();
window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash) window.scrollTo(0, 0); // Error message is displayed at the top of the page (flash)
} }
@ -62,7 +60,7 @@ function loadParentPost(event) {
fetchHtml(`/images/${imageId}/comments/${commentId}`) fetchHtml(`/images/${imageId}/comments/${commentId}`)
.then(handleError) .then(handleError)
.then(data => { .then((data) => {
clearParentPost(clickedLink, fullComment); clearParentPost(clickedLink, fullComment);
insertParentPost(data, clickedLink, fullComment); insertParentPost(data, clickedLink, fullComment);
}); });
@ -99,7 +97,7 @@ function clearParentPost(clickedLink, fullComment) {
} }
// Remove class active_reply_link from all links in the comment // Remove class active_reply_link from all links in the comment
[].slice.call(fullComment.getElementsByClassName('active_reply_link')).forEach(link => { [].slice.call(fullComment.getElementsByClassName('active_reply_link')).forEach((link) => {
link.classList.remove('active_reply_link'); link.classList.remove('active_reply_link');
}); });
@ -123,12 +121,15 @@ function loadComments(event) {
const container = document.getElementById('comments'), const container = document.getElementById('comments'),
hasHref = event.target && event.target.getAttribute('href'), hasHref = event.target && event.target.getAttribute('href'),
hasHash = window.location.hash && window.location.hash.match(/#comment_([a-f0-9]+)/), 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)}` getURL =
hasHref ||
(hasHash
? `${container.dataset.currentUrl}?comment_id=${window.location.hash.substring(9, window.location.hash.length)}`
: container.dataset.currentUrl); : container.dataset.currentUrl);
fetchHtml(getURL) fetchHtml(getURL)
.then(handleError) .then(handleError)
.then(data => { .then((data) => {
displayComments(container, data); displayComments(container, data);
// Make sure the :target CSS selector applies to the inserted content // Make sure the :target CSS selector applies to the inserted content
@ -152,8 +153,7 @@ function setupComments() {
if (!comments.dataset.loaded || !targetOnPage) { if (!comments.dataset.loaded || !targetOnPage) {
// There is no event associated with the initial load, so use false // There is no event associated with the initial load, so use false
loadComments(false); loadComments(false);
} } else {
else {
filterNode(comments); filterNode(comments);
} }
} }
@ -165,8 +165,9 @@ function setupComments() {
'#js-refresh-comments': loadComments, '#js-refresh-comments': loadComments,
}; };
document.addEventListener('click', event => { document.addEventListener('click', (event) => {
if (event.button === 0) { // Left-click only if (event.button === 0) {
// Left-click only
for (const target in targets) { for (const target in targets) {
if (event.target && event.target.closest(target)) { if (event.target && event.target.closest(target)) {
targets[target](event) && event.preventDefault(); targets[target](event) && event.preventDefault();
@ -175,7 +176,7 @@ function setupComments() {
} }
}); });
document.addEventListener('fetchcomplete', event => { document.addEventListener('fetchcomplete', (event) => {
if (event.target.id === 'js-comment-form') commentPosted(event.detail); if (event.target.id === 'js-comment-form') commentPosted(event.detail);
}); });
} }

View file

@ -11,19 +11,19 @@ const storageKey = 'cached_ses_value';
declare global { declare global {
interface Keyboard { interface Keyboard {
getLayoutMap: () => Promise<Map<string, string>> getLayoutMap: () => Promise<Map<string, string>>;
} }
interface UserAgentData { interface UserAgentData {
brands: [{brand: string, version: string}], brands: [{ brand: string; version: string }];
mobile: boolean, mobile: boolean;
platform: string, platform: string;
} }
interface Navigator { interface Navigator {
deviceMemory: number | undefined, deviceMemory: number | undefined;
keyboard: Keyboard | undefined, keyboard: Keyboard | undefined;
userAgentData: UserAgentData | undefined, userAgentData: UserAgentData | undefined;
} }
} }
@ -45,10 +45,10 @@ function cyrb53(str: string, seed: number = 0x16fe7b0a): number {
h2 = Math.imul(h2 ^ ch, 1597334677); h2 = Math.imul(h2 ^ ch, 1597334677);
} }
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507); h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ h2 >>> 13, 3266489909); h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ h1 >>> 13, 3266489909); h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0); return 4294967296 * (2097151 & h2) + (h1 >>> 0);
} }
@ -102,8 +102,8 @@ function getUserAgentBrands(): string {
// NB: Chromium implements GREASE protocol to prevent ossification of // NB: Chromium implements GREASE protocol to prevent ossification of
// the "Not a brand" string - see https://stackoverflow.com/a/64443187 // the "Not a brand" string - see https://stackoverflow.com/a/64443187
brands = data.brands brands = data.brands
.filter(e => !e.brand.match(/.*ot.*rand.*/gi)) .filter((e) => !e.brand.match(/.*ot.*rand.*/gi))
.map(e => `${e.brand}${e.version}`) .map((e) => `${e.brand}${e.version}`)
.sort() .sort()
.join(''); .join('');
} }
@ -161,9 +161,7 @@ async function createFp(): Promise<string> {
new Date().getTimezoneOffset().toString(), new Date().getTimezoneOffset().toString(),
]; ];
return cyrb53(prints.join('')) return cyrb53(prints.join('')).toString(16).padStart(14, '0');
.toString(16)
.padStart(14, '0');
} }
/** /**

View file

@ -22,7 +22,9 @@ export function setupGalleryEditing() {
initDraggables(); initDraggables();
$$<HTMLDivElement>('.media-box', containerEl).forEach(i => i.draggable = true); $$<HTMLDivElement>('.media-box', containerEl).forEach((i) => {
i.draggable = true;
});
rearrangeEl.addEventListener('click', () => { rearrangeEl.addEventListener('click', () => {
sortableEl.classList.add('editing'); sortableEl.classList.add('editing');
@ -33,8 +35,9 @@ export function setupGalleryEditing() {
sortableEl.classList.remove('editing'); sortableEl.classList.remove('editing');
containerEl.classList.remove('drag-container'); containerEl.classList.remove('drag-container');
newImages = $$<HTMLDivElement>('.image-container', containerEl) newImages = $$<HTMLDivElement>('.image-container', containerEl).map((i) =>
.map(i => parseInt(assertNotUndefined(i.dataset.imageId), 10)); parseInt(assertNotUndefined(i.dataset.imageId), 10),
);
// If nothing changed, don't bother. // If nothing changed, don't bother.
if (arraysEqual(newImages, oldImages)) return; if (arraysEqual(newImages, oldImages)) return;
@ -44,6 +47,8 @@ export function setupGalleryEditing() {
fetchJson('PATCH', reorderPath, { fetchJson('PATCH', reorderPath, {
image_ids: newImages, image_ids: newImages,
// copy the array again so that we have the newly updated set // copy the array again so that we have the newly updated set
}).then(() => oldImages = newImages.slice()); }).then(() => {
oldImages = newImages.slice();
});
}); });
} }

View file

@ -22,7 +22,7 @@ function graphSlice(el: SVGSVGElement, width: number, offset: number) {
} }
function resizeGraphs() { function resizeGraphs() {
$$<SVGSVGElement>('#js-graph-svg').forEach(el => { $$<SVGSVGElement>('#js-graph-svg').forEach((el) => {
const parent: HTMLElement | null = el.parentElement; const parent: HTMLElement | null = el.parentElement;
if (parent) { if (parent) {
@ -34,7 +34,7 @@ function resizeGraphs() {
function scaleGraph(target: HTMLElement, min: number, max: number) { function scaleGraph(target: HTMLElement, min: number, max: number) {
const targetSvg = $<SVGSVGElement>('#js-graph-svg', target); const targetSvg = $<SVGSVGElement>('#js-graph-svg', target);
if (!targetSvg) { return; } if (!targetSvg) return;
const cw = target.clientWidth; const cw = target.clientWidth;
const diff = 100 - (max - min); const diff = 100 - (max - min);
@ -47,14 +47,14 @@ function scaleGraph(target: HTMLElement, min: number, max: number) {
} }
function setupSliders() { function setupSliders() {
$$<HTMLInputElement>('#js-graph-slider').forEach(el => { $$<HTMLInputElement>('#js-graph-slider').forEach((el) => {
const targetId = el.getAttribute('data-target'); const targetId = el.getAttribute('data-target');
if (!targetId) { return; } if (!targetId) return;
const target = $<HTMLElement>(targetId); const target = $<HTMLElement>(targetId);
if (!target) { return; } if (!target) return;
el.addEventListener('input', () => { el.addEventListener('input', () => {
const min = Number(el.getAttribute('valuemin') || '0'); const min = Number(el.getAttribute('valuemin') || '0');

View file

@ -5,7 +5,7 @@ const imageVersions = {
// [width, height] // [width, height]
small: [320, 240], small: [320, 240],
medium: [800, 600], medium: [800, 600],
large: [1280, 1024] large: [1280, 1024],
}; };
/** /**
@ -91,7 +91,8 @@ function pickAndResize(elem) {
if (imageFormat === 'mp4') { if (imageFormat === 'mp4') {
elem.classList.add('full-height'); elem.classList.add('full-height');
elem.insertAdjacentHTML('afterbegin', elem.insertAdjacentHTML(
'afterbegin',
`<video controls ${autoplay} loop ${muted} playsinline preload="auto" id="image-display" `<video controls ${autoplay} loop ${muted} playsinline preload="auto" id="image-display"
width="${imageWidth}" height="${imageHeight}"> width="${imageWidth}" height="${imageHeight}">
<source src="${uris.webm}" type="video/webm"> <source src="${uris.webm}" type="video/webm">
@ -100,11 +101,11 @@ function pickAndResize(elem) {
Your browser supports neither MP4/H264 nor Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version. WebM/VP8! Please update it to the latest version.
</p> </p>
</video>` </video>`,
); );
} } else if (imageFormat === 'webm') {
else if (imageFormat === 'webm') { elem.insertAdjacentHTML(
elem.insertAdjacentHTML('afterbegin', 'afterbegin',
`<video controls ${autoplay} loop ${muted} playsinline id="image-display"> `<video controls ${autoplay} loop ${muted} playsinline id="image-display">
<source src="${uri}" type="video/webm"> <source src="${uri}" type="video/webm">
<source src="${uri.replace(/webm$/, 'mp4')}" type="video/mp4"> <source src="${uri.replace(/webm$/, 'mp4')}" type="video/mp4">
@ -112,25 +113,21 @@ function pickAndResize(elem) {
Your browser supports neither MP4/H264 nor Your browser supports neither MP4/H264 nor
WebM/VP8! Please update it to the latest version. WebM/VP8! Please update it to the latest version.
</p> </p>
</video>` </video>`,
); );
const video = elem.querySelector('video'); const video = elem.querySelector('video');
if (scaled === 'true') { if (scaled === 'true') {
video.className = 'image-scaled'; video.className = 'image-scaled';
} } else if (scaled === 'partscaled') {
else if (scaled === 'partscaled') {
video.className = 'image-partscaled'; video.className = 'image-partscaled';
} }
} } else {
else {
let image; let image;
if (scaled === 'true') { if (scaled === 'true') {
image = `<picture><img id="image-display" src="${uri}" class="image-scaled"></picture>`; image = `<picture><img id="image-display" src="${uri}" class="image-scaled"></picture>`;
} } else if (scaled === 'partscaled') {
else if (scaled === 'partscaled') {
image = `<picture><img id="image-display" src="${uri}" class="image-partscaled"></picture>`; image = `<picture><img id="image-display" src="${uri}" class="image-partscaled"></picture>`;
} } else {
else {
image = `<picture><img id="image-display" src="${uri}" width="${imageWidth}" height="${imageHeight}"></picture>`; image = `<picture><img id="image-display" src="${uri}" width="${imageWidth}" height="${imageHeight}"></picture>`;
} }
if (elem.innerHTML === image) return; if (elem.innerHTML === image) return;
@ -148,11 +145,9 @@ function bindImageForClick(target) {
target.addEventListener('click', () => { target.addEventListener('click', () => {
if (target.getAttribute('data-scaled') === 'true') { if (target.getAttribute('data-scaled') === 'true') {
target.setAttribute('data-scaled', 'partscaled'); target.setAttribute('data-scaled', 'partscaled');
} } else if (target.getAttribute('data-scaled') === 'partscaled') {
else if (target.getAttribute('data-scaled') === 'partscaled') {
target.setAttribute('data-scaled', 'false'); target.setAttribute('data-scaled', 'false');
} } else {
else {
target.setAttribute('data-scaled', 'true'); target.setAttribute('data-scaled', 'true');
} }
@ -161,7 +156,7 @@ function bindImageForClick(target) {
} }
function bindImageTarget(node = document) { function bindImageTarget(node = document) {
$$('.image-target', node).forEach(target => { $$('.image-target', node).forEach((target) => {
pickAndResize(target); pickAndResize(target);
if (target.dataset.mimeType === 'video/webm') { if (target.dataset.mimeType === 'video/webm') {

View file

@ -12,12 +12,7 @@ import { AstMatcher } from './query/types';
type CallbackType = 'tags' | 'complex'; type CallbackType = 'tags' | 'complex';
type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void; type RunCallback = (img: HTMLDivElement, tags: TagData[], type: CallbackType) => void;
function run( function run(img: HTMLDivElement, tags: TagData[], complex: AstMatcher, runCallback: RunCallback): boolean {
img: HTMLDivElement,
tags: TagData[],
complex: AstMatcher,
runCallback: RunCallback
): boolean {
const hit = (() => { const hit = (() => {
// Check tags array first to provide more precise filter explanations // Check tags array first to provide more precise filter explanations
const hitTags = imageHitsTags(img, tags); const hitTags = imageHitsTags(img, tags);
@ -56,49 +51,48 @@ function bannerImage(tagsHit: TagData[]) {
// TODO: this approach is not suitable for translations because it depends on // TODO: this approach is not suitable for translations because it depends on
// markup embedded in the page adjacent to this text // markup embedded in the page adjacent to this text
/* eslint-disable indent */
function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { function hideThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` const bannerText = type === 'tags' ? `[HIDDEN] ${displayTags(tagsHit)}` : '[HIDDEN] <i>(Complex Filter)</i>';
: '[HIDDEN] <i>(Complex Filter)</i>';
hideThumb(img, bannerImage(tagsHit), bannerText); hideThumb(img, bannerImage(tagsHit), bannerText);
} }
function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { function spoilerThumbTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? displayTags(tagsHit) const bannerText = type === 'tags' ? displayTags(tagsHit) : '<i>(Complex Filter)</i>';
: '<i>(Complex Filter)</i>';
spoilerThumb(img, bannerImage(tagsHit), bannerText); spoilerThumb(img, bannerImage(tagsHit), bannerText);
} }
function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { function hideBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by ` const bannerText =
type === 'tags'
? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is hidden by `
: 'This image was hidden by a complex tag expression in '; : 'This image was hidden by a complex tag expression in ';
spoilerBlock(img, bannerImage(tagsHit), bannerText); spoilerBlock(img, bannerImage(tagsHit), bannerText);
} }
function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) { function spoilerBlockTyped(img: HTMLDivElement, tagsHit: TagData[], type: CallbackType) {
const bannerText = type === 'tags' ? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by ` const bannerText =
type === 'tags'
? `This image is tagged <code>${escapeHtml(tagsHit[0].name)}</code>, which is spoilered by `
: 'This image was spoilered by a complex tag expression in '; : 'This image was spoilered by a complex tag expression in ';
spoilerBlock(img, bannerImage(tagsHit), bannerText); spoilerBlock(img, bannerImage(tagsHit), bannerText);
} }
/* eslint-enable indent */
export function filterNode(node: Pick<Document, 'querySelectorAll'>) { export function filterNode(node: Pick<Document, 'querySelectorAll'>) {
const hiddenTags = getHiddenTags(), spoileredTags = getSpoileredTags(); const hiddenTags = getHiddenTags(),
spoileredTags = getSpoileredTags();
const { hiddenFilter, spoileredFilter } = window.booru; const { hiddenFilter, spoileredFilter } = window.booru;
// Image thumb boxes with vote and fave buttons on them // Image thumb boxes with vote and fave buttons on them
$$<HTMLDivElement>('.image-container', node) $$<HTMLDivElement>('.image-container', node)
.filter(img => !run(img, hiddenTags, hiddenFilter, hideThumbTyped)) .filter((img) => !run(img, hiddenTags, hiddenFilter, hideThumbTyped))
.filter(img => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped)) .filter((img) => !run(img, spoileredTags, spoileredFilter, spoilerThumbTyped))
.forEach(img => showThumb(img)); .forEach((img) => showThumb(img));
// Individual image pages and images in posts/comments // Individual image pages and images in posts/comments
$$<HTMLDivElement>('.image-show-container', node) $$<HTMLDivElement>('.image-show-container', node)
.filter(img => !run(img, hiddenTags, hiddenFilter, hideBlockTyped)) .filter((img) => !run(img, hiddenTags, hiddenFilter, hideBlockTyped))
.filter(img => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped)) .filter((img) => !run(img, spoileredTags, spoileredFilter, spoilerBlockTyped))
.forEach(img => showBlock(img)); .forEach((img) => showBlock(img));
} }
export function initImagesClientside() { export function initImagesClientside() {

View file

@ -13,7 +13,7 @@ export function inputDuplicatorCreator({
addButtonSelector, addButtonSelector,
fieldSelector, fieldSelector,
maxInputCountSelector, maxInputCountSelector,
removeButtonSelector removeButtonSelector,
}: InputDuplicatorOptions) { }: InputDuplicatorOptions) {
const addButton = $<HTMLButtonElement>(addButtonSelector); const addButton = $<HTMLButtonElement>(addButtonSelector);
if (!addButton) { if (!addButton) {
@ -35,14 +35,13 @@ export function inputDuplicatorCreator({
}; };
delegate(form, 'click', { delegate(form, 'click', {
[removeButtonSelector]: leftClick(fieldRemover) [removeButtonSelector]: leftClick(fieldRemover),
}); });
const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form)); const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form));
const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10); const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10);
addButton.addEventListener('click', e => { addButton.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const existingFields = $$<HTMLElement>(fieldSelector, form); const existingFields = $$<HTMLElement>(fieldSelector, form);
@ -53,7 +52,7 @@ export function inputDuplicatorCreator({
const prevField = existingFields[existingFieldsLength - 1]; const prevField = existingFields[existingFieldsLength - 1];
const prevFieldCopy = prevField.cloneNode(true) as HTMLElement; const prevFieldCopy = prevField.cloneNode(true) as HTMLElement;
$$<HTMLInputElement>('input', prevFieldCopy).forEach(prevFieldCopyInput => { $$<HTMLInputElement>('input', prevFieldCopy).forEach((prevFieldCopyInput) => {
// Reset new input's value // Reset new input's value
prevFieldCopyInput.value = ''; prevFieldCopyInput.value = '';
prevFieldCopyInput.removeAttribute('value'); prevFieldCopyInput.removeAttribute('value');

View file

@ -6,18 +6,22 @@ import { fetchJson } from './utils/requests';
import { $ } from './utils/dom'; import { $ } from './utils/dom';
const endpoints = { const endpoints = {
vote(imageId) { return `/images/${imageId}/vote`; }, vote(imageId) {
fave(imageId) { return `/images/${imageId}/fave`; }, return `/images/${imageId}/vote`;
hide(imageId) { return `/images/${imageId}/hide`; }, },
fave(imageId) {
return `/images/${imageId}/fave`;
},
hide(imageId) {
return `/images/${imageId}/hide`;
},
}; };
const spoilerDownvoteMsg = const spoilerDownvoteMsg = 'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
'Neigh! - Remove spoilered tags from your filters to downvote from thumbnails';
/* Quick helper function to less verbosely iterate a QSA */ /* Quick helper function to less verbosely iterate a QSA */
function onImage(id, selector, cb) { function onImage(id, selector, cb) {
[].forEach.call( [].forEach.call(document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
document.querySelectorAll(`${selector}[data-image-id="${id}"]`), cb);
} }
/* Since JS modifications to webpages, except form inputs, are not stored /* Since JS modifications to webpages, except form inputs, are not stored
@ -35,28 +39,35 @@ function modifyCache(callback) {
} }
function cacheStatus(imageId, interactionType, value) { function cacheStatus(imageId, interactionType, value) {
modifyCache(cache => { modifyCache((cache) => {
cache[`${imageId}${interactionType}`] = { imageId, interactionType, value }; cache[`${imageId}${interactionType}`] = { imageId, interactionType, value };
return cache; return cache;
}); });
} }
function uncacheStatus(imageId, interactionType) { function uncacheStatus(imageId, interactionType) {
modifyCache(cache => { modifyCache((cache) => {
delete cache[`${imageId}${interactionType}`]; delete cache[`${imageId}${interactionType}`];
return cache; return cache;
}); });
} }
function setScore(imageId, data) { function setScore(imageId, data) {
onImage(imageId, '.score', onImage(imageId, '.score', (el) => {
el => el.textContent = data.score); el.textContent = data.score;
onImage(imageId, '.favorites', });
el => el.textContent = data.faves);
onImage(imageId, '.upvotes', onImage(imageId, '.favorites', (el) => {
el => el.textContent = data.upvotes); el.textContent = data.faves;
onImage(imageId, '.downvotes', });
el => el.textContent = data.downvotes);
onImage(imageId, '.upvotes', (el) => {
el.textContent = data.upvotes;
});
onImage(imageId, '.downvotes', (el) => {
el.textContent = data.downvotes;
});
} }
/* These change the visual appearance of interaction links. /* These change the visual appearance of interaction links.
@ -64,58 +75,50 @@ function setScore(imageId, data) {
function showUpvoted(imageId) { function showUpvoted(imageId) {
cacheStatus(imageId, 'voted', 'up'); cacheStatus(imageId, 'voted', 'up');
onImage(imageId, '.interaction--upvote', onImage(imageId, '.interaction--upvote', (el) => el.classList.add('active'));
el => el.classList.add('active'));
} }
function showDownvoted(imageId) { function showDownvoted(imageId) {
cacheStatus(imageId, 'voted', 'down'); cacheStatus(imageId, 'voted', 'down');
onImage(imageId, '.interaction--downvote', onImage(imageId, '.interaction--downvote', (el) => el.classList.add('active'));
el => el.classList.add('active'));
} }
function showFaved(imageId) { function showFaved(imageId) {
cacheStatus(imageId, 'faved', ''); cacheStatus(imageId, 'faved', '');
onImage(imageId, '.interaction--fave', onImage(imageId, '.interaction--fave', (el) => el.classList.add('active'));
el => el.classList.add('active'));
} }
function showHidden(imageId) { function showHidden(imageId) {
cacheStatus(imageId, 'hidden', ''); cacheStatus(imageId, 'hidden', '');
onImage(imageId, '.interaction--hide', onImage(imageId, '.interaction--hide', (el) => el.classList.add('active'));
el => el.classList.add('active'));
} }
function resetVoted(imageId) { function resetVoted(imageId) {
uncacheStatus(imageId, 'voted'); uncacheStatus(imageId, 'voted');
onImage(imageId, '.interaction--upvote', onImage(imageId, '.interaction--upvote', (el) => el.classList.remove('active'));
el => el.classList.remove('active'));
onImage(imageId, '.interaction--downvote', onImage(imageId, '.interaction--downvote', (el) => el.classList.remove('active'));
el => el.classList.remove('active'));
} }
function resetFaved(imageId) { function resetFaved(imageId) {
uncacheStatus(imageId, 'faved'); uncacheStatus(imageId, 'faved');
onImage(imageId, '.interaction--fave', onImage(imageId, '.interaction--fave', (el) => el.classList.remove('active'));
el => el.classList.remove('active'));
} }
function resetHidden(imageId) { function resetHidden(imageId) {
uncacheStatus(imageId, 'hidden'); uncacheStatus(imageId, 'hidden');
onImage(imageId, '.interaction--hide', onImage(imageId, '.interaction--hide', (el) => el.classList.remove('active'));
el => el.classList.remove('active'));
} }
function interact(type, imageId, method, data = {}) { function interact(type, imageId, method, data = {}) {
return fetchJson(method, endpoints[type](imageId), data) return fetchJson(method, endpoints[type](imageId), data)
.then(res => res.json()) .then((res) => res.json())
.then(res => setScore(imageId, res)); .then((res) => setScore(imageId, res));
} }
function displayInteractionSet(interactions) { function displayInteractionSet(interactions) {
interactions.forEach(i => { interactions.forEach((i) => {
switch (i.interaction_type) { switch (i.interaction_type) {
case 'faved': case 'faved':
showFaved(i.image_id); showFaved(i.image_id);
@ -131,7 +134,6 @@ function displayInteractionSet(interactions) {
} }
function loadInteractions() { function loadInteractions() {
/* Set up the actual interactions */ /* Set up the actual interactions */
displayInteractionSet(window.booru.interactions); displayInteractionSet(window.booru.interactions);
displayInteractionSet(getCache()); displayInteractionSet(getCache());
@ -141,68 +143,71 @@ function loadInteractions() {
if (!document.getElementById('imagelist-container')) return; if (!document.getElementById('imagelist-container')) return;
/* Users will blind downvote without this */ /* Users will blind downvote without this */
window.booru.imagesWithDownvotingDisabled.forEach(i => { window.booru.imagesWithDownvotingDisabled.forEach((i) => {
onImage(i, '.interaction--downvote', a => { onImage(i, '.interaction--downvote', (a) => {
// TODO Use a 'js-' class to target these instead // TODO Use a 'js-' class to target these instead
const icon = a.querySelector('i') || a.querySelector('.oc-icon-small'); const icon = a.querySelector('i') || a.querySelector('.oc-icon-small');
icon.setAttribute('title', spoilerDownvoteMsg); icon.setAttribute('title', spoilerDownvoteMsg);
a.classList.add('disabled'); a.classList.add('disabled');
a.addEventListener('click', event => { a.addEventListener(
'click',
(event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}, true); },
true,
);
}); });
}); });
} }
const targets = { const targets = {
/* Active-state targets first */ /* Active-state targets first */
'.interaction--upvote.active'(imageId) { '.interaction--upvote.active'(imageId) {
interact('vote', imageId, 'DELETE') interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
.then(() => resetVoted(imageId));
}, },
'.interaction--downvote.active'(imageId) { '.interaction--downvote.active'(imageId) {
interact('vote', imageId, 'DELETE') interact('vote', imageId, 'DELETE').then(() => resetVoted(imageId));
.then(() => resetVoted(imageId));
}, },
'.interaction--fave.active'(imageId) { '.interaction--fave.active'(imageId) {
interact('fave', imageId, 'DELETE') interact('fave', imageId, 'DELETE').then(() => resetFaved(imageId));
.then(() => resetFaved(imageId));
}, },
'.interaction--hide.active'(imageId) { '.interaction--hide.active'(imageId) {
interact('hide', imageId, 'DELETE') interact('hide', imageId, 'DELETE').then(() => resetHidden(imageId));
.then(() => resetHidden(imageId));
}, },
/* Inactive targets */ /* Inactive targets */
'.interaction--upvote:not(.active)'(imageId) { '.interaction--upvote:not(.active)'(imageId) {
interact('vote', imageId, 'POST', { up: true }) interact('vote', imageId, 'POST', { up: true }).then(() => {
.then(() => { resetVoted(imageId); showUpvoted(imageId); }); resetVoted(imageId);
showUpvoted(imageId);
});
}, },
'.interaction--downvote:not(.active)'(imageId) { '.interaction--downvote:not(.active)'(imageId) {
interact('vote', imageId, 'POST', { up: false }) interact('vote', imageId, 'POST', { up: false }).then(() => {
.then(() => { resetVoted(imageId); showDownvoted(imageId); }); resetVoted(imageId);
showDownvoted(imageId);
});
}, },
'.interaction--fave:not(.active)'(imageId) { '.interaction--fave:not(.active)'(imageId) {
interact('fave', imageId, 'POST') interact('fave', imageId, 'POST').then(() => {
.then(() => { resetVoted(imageId); showFaved(imageId); showUpvoted(imageId); }); resetVoted(imageId);
showFaved(imageId);
showUpvoted(imageId);
});
}, },
'.interaction--hide:not(.active)'(imageId) { '.interaction--hide:not(.active)'(imageId) {
interact('hide', imageId, 'POST') interact('hide', imageId, 'POST').then(() => {
.then(() => { showHidden(imageId); }); showHidden(imageId);
});
}, },
}; };
function bindInteractions() { function bindInteractions() {
document.addEventListener('click', event => { document.addEventListener('click', (event) => {
if (event.button === 0) {
if (event.button === 0) { // Is it a left-click? // Is it a left-click?
for (const target in targets) { for (const target in targets) {
/* Event delegation doesn't quite grab what we want here. */ /* Event delegation doesn't quite grab what we want here. */
const link = event.target && event.target.closest(target); const link = event.target && event.target.closest(target);
@ -213,21 +218,20 @@ function bindInteractions() {
} }
} }
} }
}); });
} }
function loggedOutInteractions() { function loggedOutInteractions() {
[].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'), [].forEach.call(document.querySelectorAll('.interaction--fave,.interaction--upvote,.interaction--downvote'), (a) =>
a => a.setAttribute('href', '/sessions/new')); a.setAttribute('href', '/sessions/new'),
);
} }
function setupInteractions() { function setupInteractions() {
if (window.booru.userIsSignedIn) { if (window.booru.userIsSignedIn) {
bindInteractions(); bindInteractions();
loadInteractions(); loadInteractions();
} } else {
else {
loggedOutInteractions(); loggedOutInteractions();
} }
} }

View file

@ -7,19 +7,19 @@ import { $, $$ } from './utils/dom';
const markdownSyntax = { const markdownSyntax = {
bold: { bold: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '**', shortcutKey: 'b' } options: { prefix: '**', shortcutKey: 'b' },
}, },
italics: { italics: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '*', shortcutKey: 'i' } options: { prefix: '*', shortcutKey: 'i' },
}, },
under: { under: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '__', shortcutKey: 'u' } options: { prefix: '__', shortcutKey: 'u' },
}, },
spoiler: { spoiler: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '||', shortcutKey: 's' } options: { prefix: '||', shortcutKey: 's' },
}, },
code: { code: {
action: wrapSelectionOrLines, action: wrapSelectionOrLines,
@ -29,37 +29,37 @@ const markdownSyntax = {
prefixMultiline: '```\n', prefixMultiline: '```\n',
suffixMultiline: '\n```', suffixMultiline: '\n```',
singleWrap: true, singleWrap: true,
shortcutKey: 'e' shortcutKey: 'e',
} },
}, },
strike: { strike: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '~~' } options: { prefix: '~~' },
}, },
superscript: { superscript: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '^' } options: { prefix: '^' },
}, },
subscript: { subscript: {
action: wrapSelection, action: wrapSelection,
options: { prefix: '%' } options: { prefix: '%' },
}, },
quote: { quote: {
action: wrapLines, action: wrapLines,
options: { prefix: '> ' } options: { prefix: '> ' },
}, },
link: { link: {
action: insertLink, action: insertLink,
options: { shortcutKey: 'l' } options: { shortcutKey: 'l' },
}, },
image: { image: {
action: insertLink, action: insertLink,
options: { image: true, shortcutKey: 'k' } options: { image: true, shortcutKey: 'k' },
}, },
escape: { escape: {
action: escapeSelection, action: escapeSelection,
options: { escapeChar: '\\' } options: { escapeChar: '\\' },
} },
}; };
function getSelections(textarea, linesOnly = false) { function getSelections(textarea, linesOnly = false) {
@ -78,8 +78,7 @@ function getSelections(textarea, linesOnly = false) {
const { lastIndex } = explorer; const { lastIndex } = explorer;
if (lastIndex <= selectionStart) { if (lastIndex <= selectionStart) {
startNewlineIndex = lastIndex; startNewlineIndex = lastIndex;
} } else if (lastIndex > selectionEnd) {
else if (lastIndex > selectionEnd) {
endNewlineIndex = lastIndex - 1; endNewlineIndex = lastIndex - 1;
break; break;
} }
@ -96,8 +95,7 @@ function getSelections(textarea, linesOnly = false) {
} }
selectionEnd = endNewlineIndex; selectionEnd = endNewlineIndex;
selection = textarea.value.substring(selectionStart, selectionEnd); selection = textarea.value.substring(selectionStart, selectionEnd);
} } else {
else {
// Deselect trailing space and line break // Deselect trailing space and line break
for (caret = selection.length - 1; caret > 0; caret--) { for (caret = selection.length - 1; caret > 0; caret--) {
if (selection[caret] !== ' ' && selection[caret] !== '\n') break; if (selection[caret] !== ' ' && selection[caret] !== '\n') break;
@ -117,7 +115,7 @@ function getSelections(textarea, linesOnly = false) {
processLinesOnly, processLinesOnly,
selectedText: selection, selectedText: selection,
beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace, beforeSelection: textarea.value.substring(0, selectionStart) + leadingSpace,
afterSelection: trailingSpace + textarea.value.substring(selectionEnd) afterSelection: trailingSpace + textarea.value.substring(selectionEnd),
}; };
} }
@ -130,7 +128,8 @@ function transformSelection(textarea, transformer, eachLine) {
textarea.value = beforeSelection + newText + afterSelection; textarea.value = beforeSelection + newText + afterSelection;
const newSelectionStart = caretOffset >= 1 const newSelectionStart =
caretOffset >= 1
? beforeSelection.length + caretOffset ? beforeSelection.length + caretOffset
: textarea.value.length - afterSelection.length - caretOffset; : textarea.value.length - afterSelection.length - caretOffset;
@ -157,13 +156,13 @@ function insertLink(textarea, options) {
} }
function wrapSelection(textarea, options) { function wrapSelection(textarea, options) {
transformSelection(textarea, selectedText => { transformSelection(textarea, (selectedText) => {
const { text = selectedText, prefix = '', suffix = options.prefix } = options, const { text = selectedText, prefix = '', suffix = options.prefix } = options,
emptyText = text === ''; emptyText = text === '';
let newText = text; let newText = text;
if (!emptyText) { if (!emptyText) {
newText = text.replace(/(\n{2,})/g, match => { newText = text.replace(/(\n{2,})/g, (match) => {
return suffix + match + prefix; return suffix + match + prefix;
}); });
} }
@ -172,26 +171,33 @@ function wrapSelection(textarea, options) {
return { return {
newText, newText,
caretOffset: emptyText ? prefix.length : newText.length caretOffset: emptyText ? prefix.length : newText.length,
}; };
}); });
} }
function wrapLines(textarea, options, eachLine = true) { function wrapLines(textarea, options, eachLine = true) {
transformSelection(textarea, (selectedText, processLinesOnly) => { transformSelection(
textarea,
(selectedText, processLinesOnly) => {
const { text = selectedText, singleWrap = false } = options, const { text = selectedText, singleWrap = false } = options,
prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '', prefix = (processLinesOnly && options.prefixMultiline) || options.prefix || '',
suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '', suffix = (processLinesOnly && options.suffixMultiline) || options.suffix || '',
emptyText = text === ''; emptyText = text === '';
let newText = singleWrap let newText = singleWrap
? prefix + text.trim() + suffix ? prefix + text.trim() + suffix
: text.split(/\n/g).map(line => prefix + line.trim() + suffix).join('\n'); : text
.split(/\n/g)
.map((line) => prefix + line.trim() + suffix)
.join('\n');
// Force a space at the end of lines with only blockquote markers // Force a space at the end of lines with only blockquote markers
newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> '); newText = newText.replace(/^((?:>\s+)*)>$/gm, '$1> ');
return { newText, caretOffset: emptyText ? prefix.length : newText.length }; return { newText, caretOffset: emptyText ? prefix.length : newText.length };
}, eachLine); },
eachLine,
);
} }
function wrapSelectionOrLines(textarea, options) { function wrapSelectionOrLines(textarea, options) {
@ -199,7 +205,7 @@ function wrapSelectionOrLines(textarea, options) {
} }
function escapeSelection(textarea, options) { function escapeSelection(textarea, options) {
transformSelection(textarea, selectedText => { transformSelection(textarea, (selectedText) => {
const { text = selectedText } = options, const { text = selectedText } = options,
emptyText = text === ''; emptyText = text === '';
@ -209,7 +215,7 @@ function escapeSelection(textarea, options) {
return { return {
newText, newText,
caretOffset: newText.length caretOffset: newText.length,
}; };
}); });
} }
@ -229,7 +235,14 @@ function clickHandler(event) {
} }
function shortcutHandler(event) { function shortcutHandler(event) {
if (!event.ctrlKey || (window.navigator.platform === 'MacIntel' && !event.metaKey) || event.shiftKey || event.altKey) return; if (
!event.ctrlKey ||
(window.navigator.platform === 'MacIntel' && !event.metaKey) ||
event.shiftKey ||
event.altKey
) {
return;
}
const textarea = event.target, const textarea = event.target,
key = event.key.toLowerCase(); key = event.key.toLowerCase();
@ -242,10 +255,10 @@ function shortcutHandler(event) {
} }
function setupToolbar() { function setupToolbar() {
$$('.communication__toolbar').forEach(toolbar => { $$('.communication__toolbar').forEach((toolbar) => {
toolbar.addEventListener('click', clickHandler); toolbar.addEventListener('click', clickHandler);
}); });
$$('.js-toolbar-input').forEach(textarea => { $$('.js-toolbar-input').forEach((textarea) => {
textarea.addEventListener('keydown', shortcutHandler); textarea.addEventListener('keydown', shortcutHandler);
}); });
} }

View file

@ -12,7 +12,7 @@ let touchMoved = false;
function formResult({ target, detail }: FetchcompleteEvent) { function formResult({ target, detail }: FetchcompleteEvent) {
const elements: Record<string, string> = { const elements: Record<string, string> = {
'#description-form': '.image-description', '#description-form': '.image-description',
'#uploader-form': '.image-uploader' '#uploader-form': '.image-uploader',
}; };
function showResult(formEl: HTMLFormElement, resultEl: HTMLElement, response: string) { function showResult(formEl: HTMLFormElement, resultEl: HTMLElement, response: string) {
@ -20,7 +20,7 @@ function formResult({target, detail}: FetchcompleteEvent) {
hideEl(formEl); hideEl(formEl);
showEl(resultEl); showEl(resultEl);
$$<HTMLInputElement | HTMLButtonElement>('input[type="submit"],button', formEl).forEach(button => { $$<HTMLInputElement | HTMLButtonElement>('input[type="submit"],button', formEl).forEach((button) => {
button.disabled = false; button.disabled = false;
}); });
} }
@ -30,7 +30,7 @@ function formResult({target, detail}: FetchcompleteEvent) {
const form = assertType(target, HTMLFormElement); const form = assertType(target, HTMLFormElement);
const result = assertNotNull($<HTMLElement>(resultSelector)); const result = assertNotNull($<HTMLElement>(resultSelector));
detail.text().then(text => showResult(form, result, text)); detail.text().then((text) => showResult(form, result, text));
} }
} }
} }
@ -85,11 +85,13 @@ export function setupEvents() {
} }
if (store.get('hide_score')) { if (store.get('hide_score')) {
$$<HTMLElement>('.upvotes,.score,.downvotes').forEach(s => hideEl(s)); $$<HTMLElement>('.upvotes,.score,.downvotes').forEach((s) => hideEl(s));
} }
document.addEventListener('fetchcomplete', formResult); document.addEventListener('fetchcomplete', formResult);
document.addEventListener('click', revealSpoiler); document.addEventListener('click', revealSpoiler);
document.addEventListener('touchend', revealSpoiler); document.addEventListener('touchend', revealSpoiler);
document.addEventListener('touchmove', () => touchMoved = true); document.addEventListener('touchmove', () => {
touchMoved = true;
});
} }

View file

@ -13,13 +13,13 @@ const NOTIFICATION_INTERVAL = 600000,
function bindSubscriptionLinks() { function bindSubscriptionLinks() {
delegate(document, 'fetchcomplete', { delegate(document, 'fetchcomplete', {
'.js-subscription-link': event => { '.js-subscription-link': (event) => {
const target = assertNotNull(event.target.closest('.js-subscription-target')); const target = assertNotNull(event.target.closest('.js-subscription-target'));
event.detail.text().then(text => { event.detail.text().then((text) => {
target.outerHTML = text; target.outerHTML = text;
}); });
} },
}); });
} }
@ -30,7 +30,7 @@ function getNewNotifications() {
fetchJson('GET', '/notifications/unread') fetchJson('GET', '/notifications/unread')
.then(handleError) .then(handleError)
.then(response => response.json()) .then((response) => response.json())
.then(({ notifications }) => { .then(({ notifications }) => {
updateNotificationTicker(notifications); updateNotificationTicker(notifications);
storeNotificationCount(notifications); storeNotificationCount(notifications);

View file

@ -18,8 +18,7 @@ export function warnAboutPMs() {
if (value.match(imageEmbedRegex)) { if (value.match(imageEmbedRegex)) {
showEl(warning); showEl(warning);
} } else if (!warning.classList.contains('hidden')) {
else if (!warning.classList.contains('hidden')) {
hideEl(warning); hideEl(warning);
} }
}); });

View file

@ -47,7 +47,7 @@ function getPreview(body, anonymous, previewLoading, previewIdle, previewContent
fetchJson('POST', path, { body, anonymous }) fetchJson('POST', path, { body, anonymous })
.then(handleError) .then(handleError)
.then(data => { .then((data) => {
previewContent.innerHTML = data; previewContent.innerHTML = data;
filterNode(previewContent); filterNode(previewContent);
bindImageTarget(previewContent); bindImageTarget(previewContent);
@ -110,13 +110,14 @@ function setupPreviews() {
// Fire handler for automatic resizing if textarea contains text on page load (e.g. editing) // Fire handler for automatic resizing if textarea contains text on page load (e.g. editing)
if (textarea.value) textarea.dispatchEvent(new Event('change')); if (textarea.value) textarea.dispatchEvent(new Event('change'));
previewAnon && previewAnon.addEventListener('click', () => { previewAnon &&
previewAnon.addEventListener('click', () => {
if (previewContent.classList.contains('hidden')) return; if (previewContent.classList.contains('hidden')) return;
updatePreview(); updatePreview();
}); });
document.addEventListener('click', event => { document.addEventListener('click', (event) => {
if (event.target && event.target.closest('.post-reply')) { if (event.target && event.target.closest('.post-reply')) {
const link = event.target.closest('.post-reply'); const link = event.target.closest('.post-reply');
commentReply(link.dataset.author, link.getAttribute('href'), textarea, link.dataset.post); commentReply(link.dataset.author, link.getAttribute('href'), textarea, link.dataset.post);

View file

@ -97,7 +97,9 @@ describe('Date parsing', () => {
}); });
it('should not match malformed absolute date expressions', () => { it('should not match malformed absolute date expressions', () => {
expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow('Cannot parse date string: 2024-06-21T06:21:30+01:3020'); expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow(
'Cannot parse date string: 2024-06-21T06:21:30+01:3020',
);
}); });
it('should not match malformed relative date expressions', () => { it('should not match malformed relative date expressions', () => {

View file

@ -1,11 +1,11 @@
import { AstMatcher } from './types'; import { AstMatcher } from './types';
export function matchAny(...matchers: AstMatcher[]): AstMatcher { export function matchAny(...matchers: AstMatcher[]): AstMatcher {
return (e: HTMLElement) => matchers.some(matcher => matcher(e)); return (e: HTMLElement) => matchers.some((matcher) => matcher(e));
} }
export function matchAll(...matchers: AstMatcher[]): AstMatcher { export function matchAll(...matchers: AstMatcher[]): AstMatcher {
return (e: HTMLElement) => matchers.every(matcher => matcher(e)); return (e: HTMLElement) => matchers.every((matcher) => matcher(e));
} }
export function matchNot(matcher: AstMatcher): AstMatcher { export function matchNot(matcher: AstMatcher): AstMatcher {

View file

@ -17,16 +17,16 @@ function makeMatcher(bottomDate: PosixTimeMs, topDate: PosixTimeMs, qual: RangeE
// done compared to numeric ranges. // done compared to numeric ranges.
switch (qual) { switch (qual) {
case 'lte': case 'lte':
return v => new Date(v).getTime() < topDate; return (v) => new Date(v).getTime() < topDate;
case 'gte': case 'gte':
return v => new Date(v).getTime() >= bottomDate; return (v) => new Date(v).getTime() >= bottomDate;
case 'lt': case 'lt':
return v => new Date(v).getTime() < bottomDate; return (v) => new Date(v).getTime() < bottomDate;
case 'gt': case 'gt':
return v => new Date(v).getTime() >= topDate; return (v) => new Date(v).getTime() >= topDate;
case 'eq': case 'eq':
default: default:
return v => { return (v) => {
const t = new Date(v).getTime(); const t = new Date(v).getTime();
return t >= bottomDate && t < topDate; return t >= bottomDate && t < topDate;
}; };
@ -44,7 +44,7 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
day: 86400000, day: 86400000,
week: 604800000, week: 604800000,
month: 2592000000, month: 2592000000,
year: 31536000000 year: 31536000000,
}; };
const amount = parseInt(match[1], 10); const amount = parseInt(match[1], 10);
@ -58,14 +58,7 @@ function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
} }
function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher { function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher {
const parseRes: RegExp[] = [ const parseRes: RegExp[] = [/^(\d{4})/, /^-(\d{2})/, /^-(\d{2})/, /^(?:\s+|T|t)(\d{2})/, /^:(\d{2})/, /^:(\d{2})/];
/^(\d{4})/,
/^-(\d{2})/,
/^-(\d{2})/,
/^(?:\s+|T|t)(\d{2})/,
/^:(\d{2})/,
/^:(\d{2})/
];
const timeZoneOffset: TimeZoneOffset = [0, 0]; const timeZoneOffset: TimeZoneOffset = [0, 0];
const timeData: AbsoluteDate = [0, 0, 1, 0, 0, 0]; const timeData: AbsoluteDate = [0, 0, 1, 0, 0, 0];
@ -81,8 +74,7 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
timeZoneOffset[1] *= -1; timeZoneOffset[1] *= -1;
} }
localDateVal = localDateVal.substring(0, localDateVal.length - 6); localDateVal = localDateVal.substring(0, localDateVal.length - 6);
} } else {
else {
localDateVal = localDateVal.replace(/[Zz]$/, ''); localDateVal = localDateVal.replace(/[Zz]$/, '');
} }
@ -97,16 +89,14 @@ function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): Fi
if (matchIndex === 1) { if (matchIndex === 1) {
// Months are offset by 1. // Months are offset by 1.
timeData[matchIndex] = parseInt(componentMatch[1], 10) - 1; timeData[matchIndex] = parseInt(componentMatch[1], 10) - 1;
} } else {
else {
// All other components are not offset. // All other components are not offset.
timeData[matchIndex] = parseInt(componentMatch[1], 10); timeData[matchIndex] = parseInt(componentMatch[1], 10);
} }
// Truncate string. // Truncate string.
localDateVal = localDateVal.substring(componentMatch[0].length); localDateVal = localDateVal.substring(componentMatch[0].length);
} } else {
else {
throw new ParseError(`Cannot parse date string: ${origDateVal}`); throw new ParseError(`Cannot parse date string: ${origDateVal}`);
} }
} }

View file

@ -2,16 +2,23 @@ import { FieldName } from './types';
type AttributeName = string; type AttributeName = string;
export const numberFields: FieldName[] = export const numberFields: FieldName[] = [
['id', 'width', 'height', 'aspect_ratio', 'id',
'comment_count', 'score', 'upvotes', 'downvotes', 'width',
'faves', 'tag_count', 'score']; 'height',
'aspect_ratio',
'comment_count',
'score',
'upvotes',
'downvotes',
'faves',
'tag_count',
'score',
];
export const dateFields: FieldName[] = ['created_at']; export const dateFields: FieldName[] = ['created_at'];
export const literalFields = export const literalFields = ['tags', 'orig_sha512_hash', 'sha512_hash', 'uploader', 'source_url', 'description'];
['tags', 'orig_sha512_hash', 'sha512_hash',
'uploader', 'source_url', 'description'];
export const termSpaceToImageField: Record<FieldName, AttributeName> = { export const termSpaceToImageField: Record<FieldName, AttributeName> = {
tags: 'data-image-tag-aliases', tags: 'data-image-tag-aliases',
@ -32,7 +39,7 @@ export const termSpaceToImageField: Record<FieldName, AttributeName> = {
faves: 'data-faves', faves: 'data-faves',
sha512_hash: 'data-sha512', sha512_hash: 'data-sha512',
orig_sha512_hash: 'data-orig-sha512', orig_sha512_hash: 'data-orig-sha512',
created_at: 'data-created-at' created_at: 'data-created-at',
/* eslint-enable camelcase */ /* eslint-enable camelcase */
}; };

View file

@ -17,7 +17,7 @@ const tokenList: Token[] = [
['not_op', /^\s*[!-]\s*/], ['not_op', /^\s*[!-]\s*/],
['space', /^\s+/], ['space', /^\s+/],
['word', /^(?:\\[\s,()^~]|[^\s,()^~])+/], ['word', /^(?:\\[\s,()^~]|[^\s,()^~])+/],
['word', /^(?:\\[\s,()]|[^\s,()])+/] ['word', /^(?:\\[\s,()]|[^\s,()])+/],
]; ];
export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher; export type ParseTerm = (term: string, fuzz: number, boost: number) => AstMatcher;
@ -26,9 +26,9 @@ export type Range = [number, number];
export type TermContext = [Range, string]; export type TermContext = [Range, string];
export interface LexResult { export interface LexResult {
tokenList: TokenList, tokenList: TokenList;
termContexts: TermContext[], termContexts: TermContext[];
error: ParseError | null error: ParseError | null;
} }
export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult { export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexResult {
@ -49,7 +49,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
const ret: LexResult = { const ret: LexResult = {
tokenList: [], tokenList: [],
termContexts: [], termContexts: [],
error: null error: null,
}; };
const beginTerm = (token: string) => { const beginTerm = (token: string) => {
@ -86,7 +86,10 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
const token = match[0]; const token = match[0];
if (searchTerm !== null && (['and_op', 'or_op'].indexOf(tokenName) !== -1 || tokenName === 'rparen' && lparenCtr === 0)) { if (
searchTerm !== null &&
(['and_op', 'or_op'].indexOf(tokenName) !== -1 || (tokenName === 'rparen' && lparenCtr === 0))
) {
endTerm(); endTerm();
} }
@ -107,8 +110,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (searchTerm) { if (searchTerm) {
// We're already inside a search term, so it does not apply, obv. // We're already inside a search term, so it does not apply, obv.
searchTerm += token; searchTerm += token;
} } else {
else {
negate = !negate; negate = !negate;
} }
break; break;
@ -118,8 +120,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
// instead, consider it as part of the search term, as a user convenience. // instead, consider it as part of the search term, as a user convenience.
searchTerm += token; searchTerm += token;
lparenCtr += 1; lparenCtr += 1;
} } else {
else {
opQueue.unshift('lparen'); opQueue.unshift('lparen');
groupNegate.push(negate); groupNegate.push(negate);
negate = false; negate = false;
@ -129,8 +130,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (lparenCtr > 0) { if (lparenCtr > 0) {
searchTerm = assertNotNull(searchTerm) + token; searchTerm = assertNotNull(searchTerm) + token;
lparenCtr -= 1; lparenCtr -= 1;
} } else {
else {
while (opQueue.length > 0) { while (opQueue.length > 0) {
const op = assertNotUndefined(opQueue.shift()); const op = assertNotUndefined(opQueue.shift());
if (op === 'lparen') { if (op === 'lparen') {
@ -149,8 +149,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
// to a temporary string in case this is actually inside the term. // to a temporary string in case this is actually inside the term.
fuzz = parseFloat(token.substring(1)); fuzz = parseFloat(token.substring(1));
boostFuzzStr += token; boostFuzzStr += token;
} } else {
else {
beginTerm(token); beginTerm(token);
} }
break; break;
@ -158,16 +157,14 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
if (searchTerm) { if (searchTerm) {
boost = parseFloat(token.substring(1)); boost = parseFloat(token.substring(1));
boostFuzzStr += token; boostFuzzStr += token;
} } else {
else {
beginTerm(token); beginTerm(token);
} }
break; break;
case 'quoted_lit': case 'quoted_lit':
if (searchTerm) { if (searchTerm) {
searchTerm += token; searchTerm += token;
} } else {
else {
beginTerm(token); beginTerm(token);
} }
break; break;
@ -180,8 +177,7 @@ export function generateLexResult(searchStr: string, parseTerm: ParseTerm): LexR
boostFuzzStr = ''; boostFuzzStr = '';
} }
searchTerm += token; searchTerm += token;
} } else {
else {
beginTerm(token); beginTerm(token);
} }
break; break;

View file

@ -23,11 +23,13 @@ function makeWildcardMatcher(term: string): FieldMatcher {
// A custom NFA with caching may be more sophisticated but not // A custom NFA with caching may be more sophisticated but not
// likely to be faster. // likely to be faster.
const wildcard = new RegExp( const wildcard = new RegExp(
`^${term.replace(/([.+^$[\]\\(){}|-])/g, '\\$1') `^${term
.replace(/([.+^$[\]\\(){}|-])/g, '\\$1')
.replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*') .replace(/([^\\]|[^\\](?:\\\\)+)\*/g, '$1.*')
.replace(/^(?:\\\\)*\*/g, '.*') .replace(/^(?:\\\\)*\*/g, '.*')
.replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?') .replace(/([^\\]|[^\\](?:\\\\)+)\?/g, '$1.?')
.replace(/^(?:\\\\)*\?/g, '.?')}$`, 'i' .replace(/^(?:\\\\)*\?/g, '.?')}$`,
'i',
); );
return (v, name) => { return (v, name) => {
@ -69,10 +71,9 @@ function fuzzyMatch(term: string, targetStr: string, fuzz: number): boolean {
// Insertion. // Insertion.
v2[j] + 1, v2[j] + 1,
// Substitution or No Change. // Substitution or No Change.
v1[j] + cost v1[j] + cost,
); );
if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] && if (i > 1 && j > 1 && term[i] === targetStrLower[j - 1] && targetStrLower[i - 1] === targetStrLower[j]) {
targetStrLower[i - 1] === targetStrLower[j]) {
v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost); v2[j + 1] = Math.min(v2[j], v0[j - 1] + cost);
} }
} }

View file

@ -6,10 +6,10 @@ import { makeUserMatcher } from './user';
import { FieldMatcher, RangeEqualQualifier } from './types'; import { FieldMatcher, RangeEqualQualifier } from './types';
export interface MatcherFactory { export interface MatcherFactory {
makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher, makeDateMatcher: (dateVal: string, qual: RangeEqualQualifier) => FieldMatcher;
makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher, makeLiteralMatcher: (term: string, fuzz: number, wildcardable: boolean) => FieldMatcher;
makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher, makeNumberMatcher: (term: number, fuzz: number, qual: RangeEqualQualifier) => FieldMatcher;
makeUserMatcher: (term: string) => FieldMatcher makeUserMatcher: (term: string) => FieldMatcher;
} }
export const defaultMatcher: MatcherFactory = { export const defaultMatcher: MatcherFactory = {

View file

@ -2,7 +2,7 @@ import { FieldMatcher, RangeEqualQualifier } from './types';
export function makeNumberMatcher(term: number, fuzz: number, qual: RangeEqualQualifier): FieldMatcher { export function makeNumberMatcher(term: number, fuzz: number, qual: RangeEqualQualifier): FieldMatcher {
// Range matching. // Range matching.
return v => { return (v) => {
const attrVal = parseFloat(v); const attrVal = parseFloat(v);
if (isNaN(attrVal)) { if (isNaN(attrVal)) {

View file

@ -23,19 +23,16 @@ export function parseTokens(lexicalArray: TokenList): AstMatcher {
if (token === 'and_op') { if (token === 'and_op') {
intermediate = matchAll(op1, op2); intermediate = matchAll(op1, op2);
} } else {
else {
intermediate = matchAny(op1, op2); intermediate = matchAny(op1, op2);
} }
} } else {
else {
intermediate = token; intermediate = token;
} }
if (lexicalArray[i + 1] === 'not_op') { if (lexicalArray[i + 1] === 'not_op') {
operandStack.push(matchNot(intermediate)); operandStack.push(matchNot(intermediate));
} } else {
else {
operandStack.push(intermediate); operandStack.push(intermediate);
} }
} }

View file

@ -67,11 +67,9 @@ function makeTermMatcher(term: string, fuzz: number, factory: MatcherFactory): [
} }
return [fieldName, factory.makeNumberMatcher(parseFloat(termCandidate), fuzz, rangeType)]; return [fieldName, factory.makeNumberMatcher(parseFloat(termCandidate), fuzz, rangeType)];
} } else if (literalFields.indexOf(candidateTermSpace) !== -1) {
else if (literalFields.indexOf(candidateTermSpace) !== -1) {
return [candidateTermSpace, factory.makeLiteralMatcher(termCandidate, fuzz, wildcardable)]; return [candidateTermSpace, factory.makeLiteralMatcher(termCandidate, fuzz, wildcardable)];
} } else if (candidateTermSpace === 'my') {
else if (candidateTermSpace === 'my') {
return [candidateTermSpace, factory.makeUserMatcher(termCandidate)]; return [candidateTermSpace, factory.makeUserMatcher(termCandidate)];
} }
} }

View file

@ -1,8 +1,15 @@
import { Interaction, InteractionType, InteractionValue } from '../../types/booru-object'; import { Interaction, InteractionType, InteractionValue } from '../../types/booru-object';
import { FieldMatcher } from './types'; import { FieldMatcher } from './types';
function interactionMatch(imageId: number, type: InteractionType, value: InteractionValue, interactions: Interaction[]): boolean { function interactionMatch(
return interactions.some(v => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value)); imageId: number,
type: InteractionType,
value: InteractionValue,
interactions: Interaction[],
): boolean {
return interactions.some(
(v) => v.image_id === imageId && v.interaction_type === type && (value === null || v.value === value),
);
} }
export function makeUserMatcher(term: string): FieldMatcher { export function makeUserMatcher(term: string): FieldMatcher {

View file

@ -9,56 +9,54 @@ import { fetchJson, handleError } from './utils/requests';
const imageQueueStorage = 'quickTagQueue'; const imageQueueStorage = 'quickTagQueue';
const currentTagStorage = 'quickTagName'; const currentTagStorage = 'quickTagName';
function currentQueue() { return store.get(imageQueueStorage) || []; } function currentQueue() {
return store.get(imageQueueStorage) || [];
}
function currentTags() { return store.get(currentTagStorage) || ''; } function currentTags() {
return store.get(currentTagStorage) || '';
}
function getTagButton() { return $('.js-quick-tag'); } function getTagButton() {
return $('.js-quick-tag');
}
function setTagButton(text) { $('.js-quick-tag--submit span').textContent = text; } function setTagButton(text) {
$('.js-quick-tag--submit span').textContent = text;
}
function toggleActiveState() { function toggleActiveState() {
toggleEl($('.js-quick-tag'), $('.js-quick-tag--abort'), $('.js-quick-tag--all'), $('.js-quick-tag--submit'));
toggleEl($('.js-quick-tag'),
$('.js-quick-tag--abort'),
$('.js-quick-tag--all'),
$('.js-quick-tag--submit'));
setTagButton(`Submit (${currentTags()})`); setTagButton(`Submit (${currentTags()})`);
$$('.media-box__header').forEach(el => el.classList.toggle('media-box__header--unselected')); $$('.media-box__header').forEach((el) => el.classList.toggle('media-box__header--unselected'));
$$('.media-box__header').forEach(el => el.classList.remove('media-box__header--selected')); $$('.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'))); currentQueue().forEach((id) =>
$$(`.media-box__header[data-image-id="${id}"]`).forEach((el) => el.classList.add('media-box__header--selected')),
);
} }
function activate() { function activate() {
store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:')); store.set(currentTagStorage, window.prompt('A comma-delimited list of tags you want to add:'));
if (currentTags()) toggleActiveState(); if (currentTags()) toggleActiveState();
} }
function reset() { function reset() {
store.remove(currentTagStorage); store.remove(currentTagStorage);
store.remove(imageQueueStorage); store.remove(imageQueueStorage);
toggleActiveState(); toggleActiveState();
} }
function promptReset() { function promptReset() {
if (window.confirm('Are you sure you want to abort batch tagging?')) { if (window.confirm('Are you sure you want to abort batch tagging?')) {
reset(); reset();
} }
} }
function submit() { function submit() {
setTagButton(`Wait... (${currentTags()})`); setTagButton(`Wait... (${currentTags()})`);
fetchJson('PUT', '/admin/batch/tags', { fetchJson('PUT', '/admin/batch/tags', {
@ -66,32 +64,28 @@ function submit() {
image_ids: currentQueue(), image_ids: currentQueue(),
}) })
.then(handleError) .then(handleError)
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`); if (data.failed.length) window.alert(`Failed to add tags to the images with these IDs: ${data.failed}`);
reset(); reset();
}); });
} }
function modifyImageQueue(mediaBox) { function modifyImageQueue(mediaBox) {
if (currentTags()) { if (currentTags()) {
const imageId = mediaBox.dataset.imageId, const imageId = mediaBox.dataset.imageId,
queue = currentQueue(), queue = currentQueue(),
isSelected = queue.includes(imageId); isSelected = queue.includes(imageId);
isSelected ? queue.splice(queue.indexOf(imageId), 1) isSelected ? queue.splice(queue.indexOf(imageId), 1) : queue.push(imageId);
: queue.push(imageId);
$$(`.media-box__header[data-image-id="${imageId}"]`).forEach(el => el.classList.toggle('media-box__header--selected')); $$(`.media-box__header[data-image-id="${imageId}"]`).forEach((el) =>
el.classList.toggle('media-box__header--selected'),
);
store.set(imageQueueStorage, queue); store.set(imageQueueStorage, queue);
} }
} }
function toggleAllImages() { function toggleAllImages() {
@ -99,7 +93,6 @@ function toggleAllImages() {
} }
function clickHandler(event) { function clickHandler(event) {
const targets = { const targets = {
'.js-quick-tag': activate, '.js-quick-tag': activate,
'.js-quick-tag--abort': promptReset, '.js-quick-tag--abort': promptReset,
@ -114,14 +107,11 @@ function clickHandler(event) {
currentTags() && event.preventDefault(); currentTags() && event.preventDefault();
} }
} }
} }
function setupQuickTag() { function setupQuickTag() {
if (getTagButton() && currentTags()) toggleActiveState(); if (getTagButton() && currentTags()) toggleActiveState();
if (getTagButton()) onLeftClick(clickHandler); if (getTagButton()) onLeftClick(clickHandler);
} }
export { setupQuickTag }; export { setupQuickTag };

View file

@ -2,7 +2,7 @@ import { $, $$ } from './utils/dom';
import { addTag } from './tagsinput'; import { addTag } from './tagsinput';
function showHelp(subject: string, type: string | null) { function showHelp(subject: string, type: string | null) {
$$<HTMLElement>('[data-search-help]').forEach(helpBox => { $$<HTMLElement>('[data-search-help]').forEach((helpBox) => {
if (helpBox.getAttribute('data-search-help') === type) { if (helpBox.getAttribute('data-search-help') === type) {
const searchSubject = $<HTMLElement>('.js-search-help-subject', helpBox); const searchSubject = $<HTMLElement>('.js-search-help-subject', helpBox);
@ -11,8 +11,7 @@ function showHelp(subject: string, type: string | null) {
} }
helpBox.classList.remove('hidden'); helpBox.classList.remove('hidden');
} } else {
else {
helpBox.classList.add('hidden'); helpBox.classList.add('hidden');
} }
}); });
@ -21,7 +20,8 @@ function showHelp(subject: string, type: string | null) {
function prependToLast(field: HTMLInputElement, value: string) { function prependToLast(field: HTMLInputElement, value: string) {
const separatorIndex = field.value.lastIndexOf(','); const separatorIndex = field.value.lastIndexOf(',');
const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1; const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
field.value = field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy); field.value =
field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
} }
function selectLast(field: HTMLInputElement, characterCount: number) { function selectLast(field: HTMLInputElement, characterCount: number) {
@ -32,14 +32,18 @@ function selectLast(field: HTMLInputElement, characterCount: number) {
} }
function executeFormHelper(e: PointerEvent) { function executeFormHelper(e: PointerEvent) {
if (!e.target) { return; } if (!e.target) {
return;
}
const searchField = $<HTMLInputElement>('.js-search-field'); const searchField = $<HTMLInputElement>('.js-search-field');
const attr = (name: string) => e.target && (e.target as HTMLElement).getAttribute(name); const attr = (name: string) => e.target && (e.target as HTMLElement).getAttribute(name);
if (attr('data-search-add')) addTag(searchField, attr('data-search-add')); if (attr('data-search-add')) addTag(searchField, attr('data-search-add'));
if (attr('data-search-show-help')) showHelp((e.target as Node).textContent || '', attr('data-search-show-help')); if (attr('data-search-show-help')) showHelp((e.target as Node).textContent || '', attr('data-search-show-help'));
if (attr('data-search-select-last') && searchField) selectLast(searchField, parseInt(attr('data-search-select-last') || '', 10)); if (attr('data-search-select-last') && searchField) {
selectLast(searchField, parseInt(attr('data-search-select-last') || '', 10));
}
if (attr('data-search-prepend') && searchField) prependToLast(searchField, attr('data-search-prepend') || ''); if (attr('data-search-prepend') && searchField) prependToLast(searchField, attr('data-search-prepend') || '');
} }

View file

@ -7,21 +7,18 @@ import { $, $$ } from './utils/dom';
import store from './utils/store'; import store from './utils/store';
export function setupSettings() { export function setupSettings() {
if (!$('#js-setting-table')) return; if (!$('#js-setting-table')) return;
const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]'); const localCheckboxes = $$<HTMLInputElement>('[data-tab="local"] input[type="checkbox"]');
const themeSelect = assertNotNull($<HTMLSelectElement>('#user_theme_name')); const themeSelect = assertNotNull($<HTMLSelectElement>('#user_theme_name'));
const themeColorSelect = assertNotNull($<HTMLSelectElement>('#user_theme_color')); const themeColorSelect = assertNotNull($<HTMLSelectElement>('#user_theme_color'));
const themePaths: Record<string, string> = JSON.parse( const themePaths: Record<string, string> = JSON.parse(
assertNotUndefined( assertNotUndefined(assertNotNull($<HTMLDivElement>('#js-theme-paths')).dataset.themePaths),
assertNotNull($<HTMLDivElement>('#js-theme-paths')).dataset.themePaths
)
); );
const styleSheet = assertNotNull($<HTMLLinkElement>('#js-theme-stylesheet')); const styleSheet = assertNotNull($<HTMLLinkElement>('#js-theme-stylesheet'));
// Local settings // Local settings
localCheckboxes.forEach(checkbox => { localCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', () => { checkbox.addEventListener('change', () => {
store.set(checkbox.id.replace('user_', ''), checkbox.checked); store.set(checkbox.id.replace('user_', ''), checkbox.checked);
}); });

View file

@ -37,28 +37,48 @@ function click(selector: string) {
} }
function isOK(event: KeyboardEvent): boolean { function isOK(event: KeyboardEvent): boolean {
return !event.altKey && !event.ctrlKey && !event.metaKey && return (
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
document.activeElement !== null && document.activeElement !== null &&
document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'INPUT' &&
document.activeElement.tagName !== 'TEXTAREA'; document.activeElement.tagName !== 'TEXTAREA'
);
} }
const keyCodes: ShortcutKeyMap = { const keyCodes: ShortcutKeyMap = {
'j'() { click('.js-prev'); }, // J - go to previous image j() {
'i'() { click('.js-up'); }, // I - go to index page click('.js-prev');
'k'() { click('.js-next'); }, // K - go to next image }, // J - go to previous image
'r'() { click('.js-rand'); }, // R - go to random image i() {
's'() { click('.js-source-link'); }, // S - go to image source click('.js-up');
'l'() { click('.js-tag-sauce-toggle'); }, // L - edit tags }, // I - go to index page
'o'() { openFullView(); }, // O - open original k() {
'v'() { openFullViewNewTab(); }, // V - open original in a new tab click('.js-next');
'f'() { // F - favourite image }, // K - go to next image
click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` r() {
: '.block__header a.interaction--fave'); click('.js-rand');
}, // R - go to random image
s() {
click('.js-source-link');
}, // S - go to image source
l() {
click('.js-tag-sauce-toggle');
}, // L - edit tags
o() {
openFullView();
}, // O - open original
v() {
openFullViewNewTab();
}, // V - open original in a new tab
f() {
// F - favourite image
click(getHover() ? `a.interaction--fave[data-image-id="${getHover()}"]` : '.block__header a.interaction--fave');
}, },
'u'() { // U - upvote image u() {
click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` // U - upvote image
: '.block__header a.interaction--upvote'); click(getHover() ? `a.interaction--upvote[data-image-id="${getHover()}"]` : '.block__header a.interaction--upvote');
}, },
}; };

View file

@ -34,8 +34,7 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
function clampValue(value: number): number { function clampValue(value: number): number {
if (cachedValue >= cachedLimit && value < cachedLimit) { if (cachedValue >= cachedLimit && value < cachedLimit) {
return cachedLimit; return cachedLimit;
} } else if (cachedValue < cachedLimit && value >= cachedLimit) {
else if (cachedValue < cachedLimit && value >= cachedLimit) {
return cachedLimit - 1; // Offset by 1 to ensure stored value is less than limit. return cachedLimit - 1; // Offset by 1 to ensure stored value is less than limit.
} }
@ -55,7 +54,9 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
// Initializes cached variables. Should be used // Initializes cached variables. Should be used
// when the pointer event begins. // when the pointer event begins.
function initVars() { function initVars() {
if (!parent) { return; } if (!parent) {
return;
}
const rect = parent.getBoundingClientRect(); const rect = parent.getBoundingClientRect();
@ -70,7 +71,9 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
// Called during pointer movement. // Called during pointer movement.
function dragMove(e: PointerEvent) { function dragMove(e: PointerEvent) {
if (!dragging) { return; } if (!dragging) {
return;
}
e.preventDefault(); e.preventDefault();
@ -79,13 +82,7 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
// `lerp` cleverly clamps the value between min and max, // `lerp` cleverly clamps the value between min and max,
// so no need for any explicit checks for that here, only // so no need for any explicit checks for that here, only
// the crossover check is required. // the crossover check is required.
curValue = clampValue( curValue = clampValue(lerp((desiredPos - minPos) / (maxPos - minPos), cachedMin, cachedMax));
lerp(
(desiredPos - minPos) / (maxPos - minPos),
cachedMin,
cachedMax
)
);
// Same here, lerp clamps the value so it doesn't get out // Same here, lerp clamps the value so it doesn't get out
// of the slider boundary. // of the slider boundary.
@ -99,7 +96,9 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
// Called when the pointer is let go of. // Called when the pointer is let go of.
function dragEnd(e: PointerEvent) { function dragEnd(e: PointerEvent) {
if (!dragging) { return; } if (!dragging) {
return;
}
e.preventDefault(); e.preventDefault();
@ -111,7 +110,9 @@ function setupDrag(el: HTMLDivElement, dataEl: HTMLInputElement, valueProperty:
// Called when the slider head is clicked or tapped. // Called when the slider head is clicked or tapped.
function dragBegin(e: PointerEvent) { function dragBegin(e: PointerEvent) {
if (!parent) { return; } if (!parent) {
return;
}
e.preventDefault(); e.preventDefault();
initVars(); initVars();
@ -168,7 +169,7 @@ function setupSlider(el: HTMLInputElement) {
// Sets up all sliders currently on the page. // Sets up all sliders currently on the page.
function setupSliders() { function setupSliders() {
$$<HTMLInputElement>('input[type="dualrange"]').forEach(el => { $$<HTMLInputElement>('input[type="dualrange"]').forEach((el) => {
setupSlider(el); setupSlider(el);
}); });
} }

View file

@ -19,7 +19,7 @@ export function imageSourcesCreator() {
if (target.matches('#source-form')) { if (target.matches('#source-form')) {
const sourceSauce = assertNotNull($<HTMLElement>('.js-sourcesauce')); const sourceSauce = assertNotNull($<HTMLElement>('.js-sourcesauce'));
detail.text().then(text => { detail.text().then((text) => {
sourceSauce.outerHTML = text; sourceSauce.outerHTML = text;
setupInputs(); setupInputs();
}); });

View file

@ -8,6 +8,6 @@ import { $$, hideEl } from './utils/dom';
export function hideStaffTools() { export function hideStaffTools() {
if (window.booru.hideStaffTools === 'true') { if (window.booru.hideStaffTools === 'true') {
$$<HTMLElement>('.js-staff-action').forEach(el => hideEl(el)); $$<HTMLElement>('.js-staff-action').forEach((el) => hideEl(el));
} }
} }

View file

@ -19,19 +19,46 @@ function removeTag(tagId: number, list: number[]) {
function createTagDropdown(tag: HTMLSpanElement) { function createTagDropdown(tag: HTMLSpanElement) {
const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru; const { userIsSignedIn, userCanEditFilter, watchedTagList, spoileredTagList, hiddenTagList } = window.booru;
const [ unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter ] = $$<HTMLElement>('.tag__dropdown__link', tag); const [unwatch, watch, unspoiler, spoiler, unhide, hide, signIn, filter] = $$<HTMLElement>(
'.tag__dropdown__link',
tag,
);
const [unwatched, watched, spoilered, hidden] = $$<HTMLSpanElement>('.tag__state', tag); const [unwatched, watched, spoilered, hidden] = $$<HTMLSpanElement>('.tag__state', tag);
const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10); const tagId = parseInt(assertNotUndefined(tag.dataset.tagId), 10);
const actions: TagDropdownActionList = { const actions: TagDropdownActionList = {
unwatch() { hideEl(unwatch, watched); showEl(watch, unwatched); removeTag(tagId, watchedTagList); }, unwatch() {
watch() { hideEl(watch, unwatched); showEl(unwatch, watched); addTag(tagId, watchedTagList); }, 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); }, unspoiler() {
spoiler() { hideEl(spoiler); showEl(unspoiler, spoilered); addTag(tagId, spoileredTagList); }, 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); }, unhide() {
hide() { hideEl(hide); showEl(unhide, hidden); addTag(tagId, hiddenTagList); }, hideEl(unhide, hidden);
showEl(hide);
removeTag(tagId, hiddenTagList);
},
hide() {
hideEl(hide);
showEl(unhide, hidden);
addTag(tagId, hiddenTagList);
},
}; };
const tagIsWatched = watchedTagList.includes(tagId); const tagIsWatched = watchedTagList.includes(tagId);
@ -53,10 +80,9 @@ function createTagDropdown(tag: HTMLSpanElement) {
if (userCanEditFilter) showEl(spoilerLink); if (userCanEditFilter) showEl(spoilerLink);
if (userCanEditFilter) showEl(hiddenLink); if (userCanEditFilter) showEl(hiddenLink);
if (!userIsSignedIn) showEl(signIn); if (!userIsSignedIn) showEl(signIn);
if (userIsSignedIn && if (userIsSignedIn && !userCanEditFilter) showEl(filter);
!userCanEditFilter) showEl(filter);
tag.addEventListener('fetchcomplete', event => { tag.addEventListener('fetchcomplete', (event) => {
const act = assertNotUndefined(event.target.dataset.tagAction); const act = assertNotUndefined(event.target.dataset.tagAction);
actions[act](); actions[act]();
}); });

View file

@ -42,7 +42,6 @@ function setupTagsInput(tagBlock) {
importTags(); importTags();
} }
function handleAutocomplete(event) { function handleAutocomplete(event) {
insertTag(event.detail.value); insertTag(event.detail.value);
inputField.focus(); inputField.focus();
@ -82,10 +81,9 @@ function setupTagsInput(tagBlock) {
// enter or comma // enter or comma
if (keyCode === 13 || (keyCode === 188 && !shiftKey)) { if (keyCode === 13 || (keyCode === 188 && !shiftKey)) {
event.preventDefault(); event.preventDefault();
inputField.value.split(',').forEach(t => insertTag(t)); inputField.value.split(',').forEach((t) => insertTag(t));
inputField.value = ''; inputField.value = '';
} }
} }
function handleCtrlEnter(event) { function handleCtrlEnter(event) {
@ -131,19 +129,21 @@ function setupTagsInput(tagBlock) {
container.appendChild(inputField); container.appendChild(inputField);
tags = []; tags = [];
textarea.value.split(',').forEach(t => insertTag(t)); textarea.value.split(',').forEach((t) => insertTag(t));
textarea.value = tags.join(', '); textarea.value = tags.join(', ');
} }
} }
function fancyEditorRequested(tagBlock) { function fancyEditorRequested(tagBlock) {
// Check whether the user made the fancy editor the default for each type of tag block. // 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') || return (
window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit'); (window.booru.fancyTagUpload && tagBlock.classList.contains('fancy-tag-upload')) ||
(window.booru.fancyTagEdit && tagBlock.classList.contains('fancy-tag-edit'))
);
} }
function setupTagListener() { function setupTagListener() {
document.addEventListener('addtag', event => { document.addEventListener('addtag', (event) => {
if (event.target.value) event.target.value += ', '; if (event.target.value) event.target.value += ', ';
event.target.value += event.detail.name; event.target.value += event.detail.name;
}); });

View file

@ -38,7 +38,7 @@ function tagInputButtons(event: MouseEvent) {
} }
function setupTags() { function setupTags() {
$$<HTMLDivElement>('.js-tag-block').forEach(el => { $$<HTMLDivElement>('.js-tag-block').forEach((el) => {
setupTagsInput(el); setupTagsInput(el);
el.classList.remove('js-tag-block'); el.classList.remove('js-tag-block');
}); });
@ -48,7 +48,7 @@ function updateTagSauce({ target, detail }: FetchcompleteEvent) {
if (target.matches('#tags-form')) { if (target.matches('#tags-form')) {
const tagSauce = assertNotNull($<HTMLDivElement>('.js-tagsauce')); const tagSauce = assertNotNull($<HTMLDivElement>('.js-tagsauce'));
detail.text().then(text => { detail.text().then((text) => {
tagSauce.outerHTML = text; tagSauce.outerHTML = text;
setupTags(); setupTags();
initTagDropdown(); initTagDropdown();

View file

@ -43,16 +43,16 @@ function setTimeAgo(el: HTMLTimeElement) {
years = days / 365; years = days / 365;
const words = const words =
seconds < 45 && substitute('seconds', seconds) || (seconds < 45 && substitute('seconds', seconds)) ||
seconds < 90 && substitute('minute', 1) || (seconds < 90 && substitute('minute', 1)) ||
minutes < 45 && substitute('minutes', minutes) || (minutes < 45 && substitute('minutes', minutes)) ||
minutes < 90 && substitute('hour', 1) || (minutes < 90 && substitute('hour', 1)) ||
hours < 24 && substitute('hours', hours) || (hours < 24 && substitute('hours', hours)) ||
hours < 42 && substitute('day', 1) || (hours < 42 && substitute('day', 1)) ||
days < 30 && substitute('days', days) || (days < 30 && substitute('days', days)) ||
days < 45 && substitute('month', 1) || (days < 45 && substitute('month', 1)) ||
days < 365 && substitute('months', months) || (days < 365 && substitute('months', months)) ||
years < 1.5 && substitute('year', 1) || (years < 1.5 && substitute('year', 1)) ||
substitute('years', years); substitute('years', years);
if (!el.getAttribute('title')) { if (!el.getAttribute('title')) {

View file

@ -4,7 +4,7 @@ import { fire, delegate, leftClick } from './utils/events';
const headers = () => ({ const headers = () => ({
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'XMLHttpRequest' 'x-requested-with': 'XMLHttpRequest',
}); });
function confirm(event: Event, target: HTMLElement) { function confirm(event: Event, target: HTMLElement) {
@ -25,8 +25,7 @@ function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | H
if (label) { if (label) {
target.dataset.enableWith = assertNotNull(label.nodeValue); target.dataset.enableWith = assertNotNull(label.nodeValue);
label.nodeValue = ` ${target.dataset.disableWith}`; label.nodeValue = ` ${target.dataset.disableWith}`;
} } else {
else {
target.dataset.enableWith = target.innerHTML; target.dataset.enableWith = target.innerHTML;
target.innerHTML = assertNotUndefined(target.dataset.disableWith); target.innerHTML = assertNotUndefined(target.dataset.disableWith);
} }
@ -57,8 +56,8 @@ function formRemote(event: Event, target: HTMLFormElement) {
credentials: 'same-origin', credentials: 'same-origin',
method: (target.dataset.method || target.method).toUpperCase(), method: (target.dataset.method || target.method).toUpperCase(),
headers: headers(), headers: headers(),
body: new FormData(target) body: new FormData(target),
}).then(response => { }).then((response) => {
fire(target, 'fetchcomplete', response); fire(target, 'fetchcomplete', response);
if (response && response.status === 300) { if (response && response.status === 300) {
window.location.reload(); window.location.reload();
@ -67,12 +66,11 @@ function formRemote(event: Event, target: HTMLFormElement) {
} }
function formReset(_event: Event | null, target: HTMLElement) { function formReset(_event: Event | null, target: HTMLElement) {
$$<HTMLElement>('[disabled][data-disable-with][data-enable-with]', target).forEach(input => { $$<HTMLElement>('[disabled][data-disable-with][data-enable-with]', target).forEach((input) => {
const label = findFirstTextNode(input); const label = findFirstTextNode(input);
if (label) { if (label) {
label.nodeValue = ` ${input.dataset.enableWith}`; label.nodeValue = ` ${input.dataset.enableWith}`;
} } else {
else {
input.innerHTML = assertNotUndefined(input.dataset.enableWith); input.innerHTML = assertNotUndefined(input.dataset.enableWith);
} }
delete input.dataset.enableWith; delete input.dataset.enableWith;
@ -86,10 +84,8 @@ function linkRemote(event: Event, target: HTMLAnchorElement) {
fetch(target.href, { fetch(target.href, {
credentials: 'same-origin', credentials: 'same-origin',
method: (target.dataset.method || 'get').toUpperCase(), method: (target.dataset.method || 'get').toUpperCase(),
headers: headers() headers: headers(),
}).then(response => }).then((response) => fire(target, 'fetchcomplete', response));
fire(target, 'fetchcomplete', response)
);
} }
delegate(document, 'click', { delegate(document, 'click', {
@ -100,11 +96,11 @@ delegate(document, 'click', {
}); });
delegate(document, 'submit', { delegate(document, 'submit', {
'form[data-remote]': formRemote 'form[data-remote]': formRemote,
}); });
delegate(document, 'reset', { delegate(document, 'reset', {
form: formReset form: formReset,
}); });
window.addEventListener('pageshow', () => { window.addEventListener('pageshow', () => {

View file

@ -11,7 +11,7 @@ const MATROSKA_MAGIC = 0x1a45dfa3;
function scrapeUrl(url) { function scrapeUrl(url) {
return fetchJson('POST', '/images/scrape', { url }) return fetchJson('POST', '/images/scrape', { url })
.then(handleError) .then(handleError)
.then(response => response.json()); .then((response) => response.json());
} }
function elementForEmbeddedImage({ camo_url, type }) { function elementForEmbeddedImage({ camo_url, type }) {
@ -34,7 +34,7 @@ function setupImageUpload() {
const [fileField, remoteUrl, scraperError] = $$('.js-scraper', form); const [fileField, remoteUrl, scraperError] = $$('.js-scraper', form);
const descrEl = $('.js-image-descr-input', form); const descrEl = $('.js-image-descr-input', form);
const tagsEl = $('.js-image-tags-input', form); const tagsEl = $('.js-image-tags-input', form);
const sourceEl = $$('.js-source-url', form).find(input => input.value === ''); const sourceEl = $$('.js-source-url', form).find((input) => input.value === '');
const fetchButton = $('#js-scraper-preview'); const fetchButton = $('#js-scraper-preview');
if (!fetchButton) return; if (!fetchButton) return;
@ -68,17 +68,25 @@ function setupImageUpload() {
showEl(scraperError); showEl(scraperError);
enableFetch(); enableFetch();
} }
function hideError() { hideEl(scraperError); } function hideError() {
function disableFetch() { fetchButton.setAttribute('disabled', ''); } hideEl(scraperError);
function enableFetch() { fetchButton.removeAttribute('disabled'); } }
function disableFetch() {
fetchButton.setAttribute('disabled', '');
}
function enableFetch() {
fetchButton.removeAttribute('disabled');
}
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('load', event => { reader.addEventListener('load', (event) => {
showImages([{ showImages([
{
camo_url: event.target.result, camo_url: event.target.result,
type: fileField.files[0].type type: fileField.files[0].type,
}]); },
]);
// Clear any currently cached data, because the file field // Clear any currently cached data, because the file field
// has higher priority than the scraper: // has higher priority than the scraper:
@ -88,7 +96,9 @@ function setupImageUpload() {
}); });
// Watch for files added to the form // Watch for files added to the form
fileField.addEventListener('change', () => { fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]); }); fileField.addEventListener('change', () => {
fileField.files.length && reader.readAsArrayBuffer(fileField.files[0]);
});
// Watch for [Fetch] clicks // Watch for [Fetch] clicks
fetchButton.addEventListener('click', () => { fetchButton.addEventListener('click', () => {
@ -96,13 +106,13 @@ function setupImageUpload() {
disableFetch(); disableFetch();
scrapeUrl(remoteUrl.value).then(data => { scrapeUrl(remoteUrl.value)
.then((data) => {
if (data === null) { if (data === null) {
scraperError.innerText = 'No image found at that address.'; scraperError.innerText = 'No image found at that address.';
showError(); showError();
return; return;
} } else if (data.errors && data.errors.length > 0) {
else if (data.errors && data.errors.length > 0) {
scraperError.innerText = data.errors.join(' '); scraperError.innerText = data.errors.join(' ');
showError(); showError();
return; return;
@ -121,12 +131,14 @@ function setupImageUpload() {
showImages(data.images); showImages(data.images);
enableFetch(); enableFetch();
}).catch(showError); })
.catch(showError);
}); });
// Fetch on "enter" in url field // Fetch on "enter" in url field
remoteUrl.addEventListener('keydown', event => { remoteUrl.addEventListener('keydown', (event) => {
if (event.keyCode === 13) { // Hit enter if (event.keyCode === 13) {
// Hit enter
fetchButton.click(); fetchButton.click();
} }
}); });
@ -135,8 +147,7 @@ function setupImageUpload() {
function setFetchEnabled() { function setFetchEnabled() {
if (remoteUrl.value.length > 0) { if (remoteUrl.value.length > 0) {
enableFetch(); enableFetch();
} } else {
else {
disableFetch(); disableFetch();
} }
} }

View file

@ -84,15 +84,17 @@ describe('Array Utilities', () => {
// Mixed parameters // Mixed parameters
const mockObject = { value: Math.random() }; const mockObject = { value: Math.random() };
expect(arraysEqual( expect(
arraysEqual(
['', null, false, uniqueValue, mockObject, Infinity, undefined], ['', null, false, uniqueValue, mockObject, Infinity, undefined],
['', null, false, uniqueValue, mockObject, Infinity, undefined] ['', null, false, uniqueValue, mockObject, Infinity, undefined],
)).toBe(true); ),
).toBe(true);
}); });
}); });
describe('negative cases', () => { describe('negative cases', () => {
it('should NOT return true for matching only up to the first array\'s length', () => { it("should NOT return true for matching only up to the first array's length", () => {
// Numbers // Numbers
expect(arraysEqual([0], [0, 1])).toBe(false); expect(arraysEqual([0], [0, 1])).toBe(false);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false); expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
@ -108,26 +110,15 @@ describe('Array Utilities', () => {
// Mixed parameters // Mixed parameters
const mockObject = { value: Math.random() }; const mockObject = { value: Math.random() };
expect(arraysEqual( expect(arraysEqual([''], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
[''], expect(arraysEqual(['', null], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
['', null, false, mockObject, Infinity, undefined] expect(arraysEqual(['', null, false], ['', null, false, mockObject, Infinity, undefined])).toBe(false);
)).toBe(false); expect(arraysEqual(['', null, false, mockObject], ['', null, false, mockObject, Infinity, undefined])).toBe(
expect(arraysEqual( false,
['', null], );
['', null, false, mockObject, Infinity, undefined] expect(
)).toBe(false); arraysEqual(['', null, false, mockObject, Infinity], ['', null, false, mockObject, Infinity, undefined]),
expect(arraysEqual( ).toBe(false);
['', null, false],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
expect(arraysEqual(
['', null, false, mockObject, Infinity],
['', null, false, mockObject, Infinity, undefined]
)).toBe(false);
}); });
it('should return false for arrays of different length', () => { it('should return false for arrays of different length', () => {
@ -151,7 +142,7 @@ describe('Array Utilities', () => {
expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false); expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false);
}); });
it('should return false if items up to the first array\'s length differ', () => { it("should return false if items up to the first array's length differ", () => {
// Numbers // Numbers
expect(arraysEqual([0], [1])).toBe(false); expect(arraysEqual([0], [1])).toBe(false);
expect(arraysEqual([0, 1], [1, 2])).toBe(false); expect(arraysEqual([0, 1], [1, 2])).toBe(false);
@ -168,22 +159,12 @@ describe('Array Utilities', () => {
expect(arraysEqual([mockObject1], [mockObject2])).toBe(false); expect(arraysEqual([mockObject1], [mockObject2])).toBe(false);
// Mixed parameters // Mixed parameters
expect(arraysEqual( expect(arraysEqual(['a'], ['b', null, false, mockObject2, Infinity])).toBe(false);
['a'], expect(arraysEqual(['a', null, true], ['b', null, false, mockObject2, Infinity])).toBe(false);
['b', null, false, mockObject2, Infinity] expect(arraysEqual(['a', null, true, mockObject1], ['b', null, false, mockObject2, Infinity])).toBe(false);
)).toBe(false); expect(arraysEqual(['a', null, true, mockObject1, -Infinity], ['b', null, false, mockObject2, Infinity])).toBe(
expect(arraysEqual( false,
['a', null, true], );
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1, -Infinity],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
}); });
}); });
}); });

View file

@ -87,11 +87,7 @@ describe('DOM Utilities', () => {
}); });
it(`should remove the ${hiddenClass} class from all provided elements`, () => { it(`should remove the ${hiddenClass} class from all provided elements`, () => {
const mockElements = [ const mockElements = [createHiddenElement('div'), createHiddenElement('a'), createHiddenElement('strong')];
createHiddenElement('div'),
createHiddenElement('a'),
createHiddenElement('strong'),
];
showEl(mockElements); showEl(mockElements);
expect(mockElements[0]).not.toHaveClass(hiddenClass); expect(mockElements[0]).not.toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass); expect(mockElements[1]).not.toHaveClass(hiddenClass);
@ -99,14 +95,8 @@ describe('DOM Utilities', () => {
}); });
it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => { it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
const mockElements1 = [ const mockElements1 = [createHiddenElement('div'), createHiddenElement('a')];
createHiddenElement('div'), const mockElements2 = [createHiddenElement('strong'), createHiddenElement('em')];
createHiddenElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
createHiddenElement('em'),
];
showEl(mockElements1, mockElements2); showEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass); expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).not.toHaveClass(hiddenClass); expect(mockElements1[1]).not.toHaveClass(hiddenClass);
@ -135,14 +125,8 @@ describe('DOM Utilities', () => {
}); });
it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => { it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
const mockElements1 = [ const mockElements1 = [document.createElement('div'), document.createElement('a')];
document.createElement('div'), const mockElements2 = [document.createElement('strong'), document.createElement('em')];
document.createElement('a'),
];
const mockElements2 = [
document.createElement('strong'),
document.createElement('em'),
];
hideEl(mockElements1, mockElements2); hideEl(mockElements1, mockElements2);
expect(mockElements1[0]).toHaveClass(hiddenClass); expect(mockElements1[0]).toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass); expect(mockElements1[1]).toHaveClass(hiddenClass);
@ -159,24 +143,15 @@ describe('DOM Utilities', () => {
}); });
it('should set the disabled attribute to true on all provided elements', () => { it('should set the disabled attribute to true on all provided elements', () => {
const mockElements = [ const mockElements = [document.createElement('input'), document.createElement('button')];
document.createElement('input'),
document.createElement('button'),
];
disableEl(mockElements); disableEl(mockElements);
expect(mockElements[0]).toBeDisabled(); expect(mockElements[0]).toBeDisabled();
expect(mockElements[1]).toBeDisabled(); expect(mockElements[1]).toBeDisabled();
}); });
it('should set the disabled attribute to true on elements provided in multiple arrays', () => { it('should set the disabled attribute to true on elements provided in multiple arrays', () => {
const mockElements1 = [ const mockElements1 = [document.createElement('input'), document.createElement('button')];
document.createElement('input'), const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
document.createElement('button'),
];
const mockElements2 = [
document.createElement('textarea'),
document.createElement('button'),
];
disableEl(mockElements1, mockElements2); disableEl(mockElements1, mockElements2);
expect(mockElements1[0]).toBeDisabled(); expect(mockElements1[0]).toBeDisabled();
expect(mockElements1[1]).toBeDisabled(); expect(mockElements1[1]).toBeDisabled();
@ -193,24 +168,15 @@ describe('DOM Utilities', () => {
}); });
it('should set the disabled attribute to false on all provided elements', () => { it('should set the disabled attribute to false on all provided elements', () => {
const mockElements = [ const mockElements = [document.createElement('input'), document.createElement('button')];
document.createElement('input'),
document.createElement('button'),
];
enableEl(mockElements); enableEl(mockElements);
expect(mockElements[0]).toBeEnabled(); expect(mockElements[0]).toBeEnabled();
expect(mockElements[1]).toBeEnabled(); expect(mockElements[1]).toBeEnabled();
}); });
it('should set the disabled attribute to false on elements provided in multiple arrays', () => { it('should set the disabled attribute to false on elements provided in multiple arrays', () => {
const mockElements1 = [ const mockElements1 = [document.createElement('input'), document.createElement('button')];
document.createElement('input'), const mockElements2 = [document.createElement('textarea'), document.createElement('button')];
document.createElement('button'),
];
const mockElements2 = [
document.createElement('textarea'),
document.createElement('button'),
];
enableEl(mockElements1, mockElements2); enableEl(mockElements1, mockElements2);
expect(mockElements1[0]).toBeEnabled(); expect(mockElements1[0]).toBeEnabled();
expect(mockElements1[1]).toBeEnabled(); expect(mockElements1[1]).toBeEnabled();
@ -245,14 +211,8 @@ describe('DOM Utilities', () => {
}); });
it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => { it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
const mockElements1 = [ const mockElements1 = [createHiddenElement('div'), document.createElement('a')];
createHiddenElement('div'), const mockElements2 = [createHiddenElement('strong'), document.createElement('em')];
document.createElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
document.createElement('em'),
];
toggleEl(mockElements1, mockElements2); toggleEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass); expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass); expect(mockElements1[1]).toHaveClass(hiddenClass);
@ -430,8 +390,7 @@ describe('DOM Utilities', () => {
try { try {
whenReady(mockCallback); whenReady(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(1); expect(mockCallback).toHaveBeenCalledTimes(1);
} } finally {
finally {
readyStateSpy.mockRestore(); readyStateSpy.mockRestore();
} }
}); });
@ -446,8 +405,7 @@ describe('DOM Utilities', () => {
expect(addEventListenerSpy).toHaveBeenCalledTimes(1); expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback); expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
expect(mockCallback).not.toHaveBeenCalled(); expect(mockCallback).not.toHaveBeenCalled();
} } finally {
finally {
readyStateSpy.mockRestore(); readyStateSpy.mockRestore();
addEventListenerSpy.mockRestore(); addEventListenerSpy.mockRestore();
} }
@ -456,7 +414,9 @@ describe('DOM Utilities', () => {
describe('escapeHtml', () => { describe('escapeHtml', () => {
it('should replace only the expected characters with their HTML entity equivalents', () => { it('should replace only the expected characters with their HTML entity equivalents', () => {
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe('&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;'); expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe(
'&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;',
);
}); });
}); });

View file

@ -14,7 +14,7 @@ describe('Draggable Utilities', () => {
items: items as unknown as DataTransferItemList, items: items as unknown as DataTransferItemList,
setData(format: string, data: string) { setData(format: string, data: string) {
items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) }); items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) });
} },
} as unknown as DataTransfer; } as unknown as DataTransfer;
} }
Object.assign(mockEvent, { dataTransfer }); Object.assign(mockEvent, { dataTransfer });
@ -44,7 +44,6 @@ describe('Draggable Utilities', () => {
mockDraggable = createDraggableElement(); mockDraggable = createDraggableElement();
mockDragContainer.appendChild(mockDraggable); mockDragContainer.appendChild(mockDraggable);
// Redirect all document event listeners to this element for easier cleanup // Redirect all document event listeners to this element for easier cleanup
documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => { documentEventListenerSpy = vi.spyOn(document, 'addEventListener').mockImplementation((...params) => {
mockDragContainer.addEventListener(...params); mockDragContainer.addEventListener(...params);
@ -67,7 +66,7 @@ describe('Draggable Utilities', () => {
expect(mockDraggable).toHaveClass(draggingClass); expect(mockDraggable).toHaveClass(draggingClass);
}); });
it('should add dummy data to the dragstart event if it\'s empty', () => { it("should add dummy data to the dragstart event if it's empty", () => {
initDraggables(); initDraggables();
const mockEvent = createDragEvent('dragstart'); const mockEvent = createDragEvent('dragstart');
@ -81,13 +80,13 @@ describe('Draggable Utilities', () => {
expect(dataTransferItem.type).toEqual('text/plain'); expect(dataTransferItem.type).toEqual('text/plain');
let stringValue: string | undefined; let stringValue: string | undefined;
dataTransferItem.getAsString(value => { dataTransferItem.getAsString((value) => {
stringValue = value; stringValue = value;
}); });
expect(stringValue).toEqual(''); expect(stringValue).toEqual('');
}); });
it('should keep data in the dragstart event if it\'s present', () => { it("should keep data in the dragstart event if it's present", () => {
initDraggables(); initDraggables();
const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']); const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']);
@ -95,7 +94,9 @@ describe('Draggable Utilities', () => {
type: mockTransferItemType, type: mockTransferItemType,
} as unknown as DataTransferItem; } as unknown as DataTransferItem;
const mockEvent = createDragEvent('dragstart', { dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList } } as DragEventInit); const mockEvent = createDragEvent('dragstart', {
dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList },
} as DragEventInit);
expect(mockEvent.dataTransfer?.items).toHaveLength(1); expect(mockEvent.dataTransfer?.items).toHaveLength(1);
fireEvent(mockDraggable, mockEvent); fireEvent(mockDraggable, mockEvent);
@ -203,8 +204,7 @@ describe('Draggable Utilities', () => {
expect(mockDropEvent.defaultPrevented).toBe(true); expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass); expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable); expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable);
} } finally {
finally {
boundingBoxSpy.mockRestore(); boundingBoxSpy.mockRestore();
} }
}); });
@ -232,8 +232,7 @@ describe('Draggable Utilities', () => {
expect(mockDropEvent.defaultPrevented).toBe(true); expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass); expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable); expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable);
} } finally {
finally {
boundingBoxSpy.mockRestore(); boundingBoxSpy.mockRestore();
} }
}); });
@ -254,7 +253,7 @@ describe('Draggable Utilities', () => {
}); });
describe('dragEnd', () => { describe('dragEnd', () => {
it('should remove dragging class from source and over class from target\'s descendants', () => { it("should remove dragging class from source and over class from target's descendants", () => {
initDraggables(); initDraggables();
const mockStartEvent = createDragEvent('dragstart'); const mockStartEvent = createDragEvent('dragstart');
@ -298,8 +297,7 @@ describe('Draggable Utilities', () => {
fireEvent(mockDraggable, mockEvent); fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy(); expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
} } finally {
finally {
draggableClosestSpy.mockRestore(); draggableClosestSpy.mockRestore();
} }
}); });

View file

@ -60,7 +60,7 @@ describe('Event utils', () => {
const mockButton = document.createElement('button'); const mockButton = document.createElement('button');
const mockHandler = vi.fn(); const mockHandler = vi.fn();
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton)); mockButton.addEventListener('click', (e) => leftClick(mockHandler)(e, mockButton));
fireEvent.click(mockButton, { button: 0 }); fireEvent.click(mockButton, { button: 0 });
@ -72,7 +72,7 @@ describe('Event utils', () => {
const mockHandler = vi.fn(); const mockHandler = vi.fn();
const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]); const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]);
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton)); mockButton.addEventListener('click', (e) => leftClick(mockHandler)(e, mockButton));
fireEvent.click(mockButton, { button: mockButtonNumber }); fireEvent.click(mockButton, { button: mockButtonNumber });

View file

@ -92,7 +92,7 @@ describe('Image utils', () => {
extension: string; extension: string;
videoClasses?: string[]; videoClasses?: string[];
imgClasses?: string[]; imgClasses?: string[];
} };
const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => { const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => {
const mockElement = document.createElement('div'); const mockElement = document.createElement('div');
@ -101,7 +101,7 @@ describe('Image utils', () => {
const mockImage = new Image(); const mockImage = new Image();
mockImage.src = mockImageUri; mockImage.src = mockImageUri;
if (imgClasses) { if (imgClasses) {
imgClasses.forEach(videoClass => { imgClasses.forEach((videoClass) => {
mockImage.classList.add(videoClass); mockImage.classList.add(videoClass);
}); });
} }
@ -109,7 +109,7 @@ describe('Image utils', () => {
const mockVideo = document.createElement('video'); const mockVideo = document.createElement('video');
if (videoClasses) { if (videoClasses) {
videoClasses.forEach(videoClass => { videoClasses.forEach((videoClass) => {
mockVideo.classList.add(videoClass); mockVideo.classList.add(videoClass);
}); });
} }
@ -131,15 +131,8 @@ describe('Image utils', () => {
}; };
it('should hide the img element and show the video instead if no picture element is present', () => { it('should hide the img element and show the video instead if no picture element is present', () => {
const { const { mockElement, mockImage, playSpy, mockVideo, mockSize, mockSizeUrls, mockSpoilerOverlay } =
mockElement, createMockElements({
mockImage,
playSpy,
mockVideo,
mockSize,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElements({
extension: 'webm', extension: 'webm',
videoClasses: ['hidden'], videoClasses: ['hidden'],
}); });
@ -168,7 +161,7 @@ describe('Image utils', () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
['data-size', 'data-uris'].forEach(missingAttributeName => { ['data-size', 'data-uris'].forEach((missingAttributeName) => {
it(`should return early if the ${missingAttributeName} attribute is missing`, () => { it(`should return early if the ${missingAttributeName} attribute is missing`, () => {
const { mockElement } = createMockElements({ const { mockElement } = createMockElements({
extension: 'webm', extension: 'webm',
@ -181,8 +174,7 @@ describe('Image utils', () => {
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(result).toBe(false); expect(result).toBe(false);
expect(jsonParseSpy).not.toHaveBeenCalled(); expect(jsonParseSpy).not.toHaveBeenCalled();
} } finally {
finally {
jsonParseSpy.mockRestore(); jsonParseSpy.mockRestore();
} }
}); });
@ -226,13 +218,8 @@ describe('Image utils', () => {
}); });
it('should show the correct thumbnail image for jpg extension', () => { it('should show the correct thumbnail image for jpg extension', () => {
const { const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } =
mockElement, createMockElementWithPicture('jpg');
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg');
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -243,13 +230,8 @@ describe('Image utils', () => {
}); });
it('should show the correct thumbnail image for gif extension', () => { it('should show the correct thumbnail image for gif extension', () => {
const { const { mockElement, mockSizeImage, mockSizeUrls, mockSize, mockSpoilerOverlay } =
mockElement, createMockElementWithPicture('gif');
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('gif');
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -260,13 +242,8 @@ describe('Image utils', () => {
}); });
it('should show the correct thumbnail image for webm extension', () => { it('should show the correct thumbnail image for webm extension', () => {
const { const { mockElement, mockSpoilerOverlay, mockSizeImage, mockSizeUrls, mockSize } =
mockElement, createMockElementWithPicture('webm');
mockSpoilerOverlay,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('webm');
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif')); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif'));
@ -284,12 +261,10 @@ describe('Image utils', () => {
}); });
const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => { const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => {
const { const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture(
mockElement, 'jpg',
mockSizeImage, size,
mockSizeUrls, );
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg', size);
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[size]); expect(mockSizeImage.src).toBe(mockSizeUrls[size]);
@ -312,12 +287,10 @@ describe('Image utils', () => {
it('should NOT set srcset on img if thumbUri is a gif at small size', () => { it('should NOT set srcset on img if thumbUri is a gif at small size', () => {
const mockSize = 'small'; const mockSize = 'small';
const { const { mockElement, mockSizeImage, mockSizeUrls, mockSpoilerOverlay } = createMockElementWithPicture(
mockElement, 'gif',
mockSizeImage, mockSize,
mockSizeUrls, );
mockSpoilerOverlay,
} = createMockElementWithPicture('gif', mockSize);
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]); expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
@ -336,12 +309,7 @@ describe('Image utils', () => {
}); });
it('should return false if img source already matches thumbUri', () => { it('should return false if img source already matches thumbUri', () => {
const { const { mockElement, mockSizeImage, mockSizeUrls, mockSize } = createMockElementWithPicture('jpg');
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('jpg');
mockSizeImage.src = mockSizeUrls[mockSize]; mockSizeImage.src = mockSizeUrls[mockSize];
const result = showThumb(mockElement); const result = showThumb(mockElement);
expect(result).toBe(false); expect(result).toBe(false);
@ -408,8 +376,7 @@ describe('Image utils', () => {
expect(querySelectorSpy).toHaveBeenCalledTimes(2); expect(querySelectorSpy).toHaveBeenCalledTimes(2);
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video'); expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
} } finally {
finally {
querySelectorSpy.mockRestore(); querySelectorSpy.mockRestore();
} }
}); });
@ -430,8 +397,7 @@ describe('Image utils', () => {
expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img'); expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img');
expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`); expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`);
expect(mockVideo).not.toHaveClass(hiddenClass); expect(mockVideo).not.toHaveClass(hiddenClass);
} } finally {
finally {
querySelectorSpy.mockRestore(); querySelectorSpy.mockRestore();
pauseSpy.mockRestore(); pauseSpy.mockRestore();
} }
@ -458,8 +424,7 @@ describe('Image utils', () => {
expect(mockVideo).toBeEmptyDOMElement(); expect(mockVideo).toBeEmptyDOMElement();
expect(mockVideo).toHaveClass(hiddenClass); expect(mockVideo).toHaveClass(hiddenClass);
expect(pauseSpy).toHaveBeenCalled(); expect(pauseSpy).toHaveBeenCalled();
} } finally {
finally {
pauseSpy.mockRestore(); pauseSpy.mockRestore();
} }
}); });
@ -482,8 +447,7 @@ describe('Image utils', () => {
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture'); expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img'); expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img');
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`); expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`);
} } finally {
finally {
imgQuerySelectorSpy.mockRestore(); imgQuerySelectorSpy.mockRestore();
pictureQuerySelectorSpy.mockRestore(); pictureQuerySelectorSpy.mockRestore();
} }

View file

@ -78,9 +78,7 @@ describe('Local Autocompleter', () => {
it('should return namespaced suggestions without including namespace', () => { it('should return namespaced suggestions without including namespace', () => {
const result = localAc.topK('test', defaultK); const result = localAc.topK('test', defaultK);
expect(result).toEqual([ expect(result).toEqual([expect.objectContaining({ name: 'artist:test', imageCount: 1 })]);
expect.objectContaining({ name: 'artist:test', imageCount: 1 }),
]);
}); });
it('should return only the required number of suggestions', () => { it('should return only the required number of suggestions', () => {

View file

@ -29,7 +29,7 @@ describe('Request utils', () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest' 'x-requested-with': 'xmlhttprequest',
}, },
}); });
}); });
@ -46,12 +46,12 @@ describe('Request utils', () => {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest' 'x-requested-with': 'xmlhttprequest',
}, },
body: JSON.stringify({ body: JSON.stringify({
...mockBody, ...mockBody,
_method: mockVerb _method: mockVerb,
}) }),
}); });
}); });
}); });
@ -64,7 +64,7 @@ describe('Request utils', () => {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest' 'x-requested-with': 'xmlhttprequest',
}, },
}); });
}); });

View file

@ -60,9 +60,11 @@ describe('Store utilities', () => {
}, },
}; };
const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[]; const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[];
setStorageValue(initialValueKeys.reduce((acc, key) => { setStorageValue(
initialValueKeys.reduce((acc, key) => {
return { ...acc, [key]: JSON.stringify(initialValues[key]) }; return { ...acc, [key]: JSON.stringify(initialValues[key]) };
}, {})); }, {}),
);
initialValueKeys.forEach((key, i) => { initialValueKeys.forEach((key, i) => {
const result = store.get(key); const result = store.get(key);
@ -166,7 +168,11 @@ describe('Store utilities', () => {
expect(setItemSpy).toHaveBeenCalledTimes(2); expect(setItemSpy).toHaveBeenCalledTimes(2);
expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue)); expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue));
expect(setItemSpy).toHaveBeenNthCalledWith(2, mockKey + lastUpdatedSuffix, JSON.stringify(initialDateNow + mockMaxAge)); expect(setItemSpy).toHaveBeenNthCalledWith(
2,
mockKey + lastUpdatedSuffix,
JSON.stringify(initialDateNow + mockMaxAge),
);
}); });
}); });

View file

@ -57,7 +57,7 @@ describe('Tag utilities', () => {
}); });
describe('getHiddenTags', () => { describe('getHiddenTags', () => {
it('should get a single hidden tag\'s information', () => { it("should get a single hidden tag's information", () => {
window.booru.hiddenTagList = [1, 1]; window.booru.hiddenTagList = [1, 1];
const result = getHiddenTags(); const result = getHiddenTags();
@ -72,12 +72,7 @@ describe('Tag utilities', () => {
const result = getHiddenTags(); const result = getHiddenTags();
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
expect(result).toEqual([ expect(result).toEqual([mockTagInfo[3], mockTagInfo[2], mockTagInfo[1], mockTagInfo[4]]);
mockTagInfo[3],
mockTagInfo[2],
mockTagInfo[1],
mockTagInfo[4],
]);
}); });
}); });
@ -91,7 +86,7 @@ describe('Tag utilities', () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it('should get a single spoilered tag\'s information', () => { it("should get a single spoilered tag's information", () => {
window.booru.spoileredTagList = [1, 1]; window.booru.spoileredTagList = [1, 1];
window.booru.ignoredTagList = []; window.booru.ignoredTagList = [];
window.booru.spoilerType = getEnabledSpoilerType(); window.booru.spoilerType = getEnabledSpoilerType();
@ -110,12 +105,7 @@ describe('Tag utilities', () => {
const result = getSpoileredTags(); const result = getSpoileredTags();
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
expect(result).toEqual([ expect(result).toEqual([mockTagInfo[2], mockTagInfo[3], mockTagInfo[1], mockTagInfo[4]]);
mockTagInfo[2],
mockTagInfo[3],
mockTagInfo[1],
mockTagInfo[4],
]);
}); });
it('should omit ignored tags from the list', () => { it('should omit ignored tags from the list', () => {
@ -125,10 +115,7 @@ describe('Tag utilities', () => {
const result = getSpoileredTags(); const result = getSpoileredTags();
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result).toEqual([ expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]);
mockTagInfo[1],
mockTagInfo[4],
]);
}); });
}); });
@ -140,10 +127,7 @@ describe('Tag utilities', () => {
const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]); const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]);
expect(result).toHaveLength(mockImageTags.length); expect(result).toHaveLength(mockImageTags.length);
expect(result).toEqual([ expect(result).toEqual([mockTagInfo[1], mockTagInfo[4]]);
mockTagInfo[1],
mockTagInfo[4],
]);
}); });
it('should return empty array if data attribute is missing', () => { it('should return empty array if data attribute is missing', () => {
@ -174,12 +158,16 @@ describe('Tag utilities', () => {
it('should return the correct value for two tags', () => { it('should return the correct value for two tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4]]); const result = displayTags([mockTagInfo[1], mockTagInfo[4]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`); expect(result).toEqual(
`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`,
);
}); });
it('should return the correct value for three tags', () => { it('should return the correct value for three tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]); const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`); expect(result).toEqual(
`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`,
);
}); });
it('should escape HTML in the tag name', () => { it('should escape HTML in the tag name', () => {

View file

@ -5,54 +5,63 @@ type PhilomenaInputElements = HTMLTextAreaElement | HTMLInputElement | HTMLButto
/** /**
* Get the first matching element * Get the first matching element
*/ */
export function $<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelector'> = document): E | null { export function $<E extends Element = Element>(
selector: string,
context: Pick<Document, 'querySelector'> = document,
): E | null {
return context.querySelector<E>(selector); return context.querySelector<E>(selector);
} }
/** /**
* Get every matching element as an array * Get every matching element as an array
*/ */
export function $$<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelectorAll'> = document): E[] { export function $$<E extends Element = Element>(
selector: string,
context: Pick<Document, 'querySelectorAll'> = document,
): E[] {
const elements = context.querySelectorAll<E>(selector); const elements = context.querySelectorAll<E>(selector);
return [...elements]; return [...elements];
} }
export function showEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function showEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.remove('hidden')); ([] as E[]).concat(...elements).forEach((el) => el.classList.remove('hidden'));
} }
export function hideEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function hideEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.add('hidden')); ([] as E[]).concat(...elements).forEach((el) => el.classList.add('hidden'));
} }
export function toggleEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function toggleEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.classList.toggle('hidden')); ([] as E[]).concat(...elements).forEach((el) => el.classList.toggle('hidden'));
} }
export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => { ([] as E[]).concat(...elements).forEach((el) => {
while (el.firstChild) el.removeChild(el.firstChild); while (el.firstChild) el.removeChild(el.firstChild);
}); });
} }
export function disableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) { export function disableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => { ([] as E[]).concat(...elements).forEach((el) => {
el.disabled = true; el.disabled = true;
}); });
} }
export function enableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) { export function enableEl<E extends PhilomenaInputElements>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => { ([] as E[]).concat(...elements).forEach((el) => {
el.disabled = false; el.disabled = false;
}); });
} }
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) { export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
([] as E[]).concat(...elements).forEach(el => el.parentNode?.removeChild(el)); ([] as E[]).concat(...elements).forEach((el) => el.parentNode?.removeChild(el));
} }
export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?: Partial<HTMLElementTagNameMap[Tag]>): HTMLElementTagNameMap[Tag] { export function makeEl<Tag extends keyof HTMLElementTagNameMap>(
tag: Tag,
attr?: Partial<HTMLElementTagNameMap[Tag]>,
): HTMLElementTagNameMap[Tag] {
const el = document.createElement(tag); const el = document.createElement(tag);
if (attr) { if (attr) {
for (const prop in attr) { for (const prop in attr) {
@ -65,8 +74,11 @@ export function makeEl<Tag extends keyof HTMLElementTagNameMap>(tag: Tag, attr?:
return el; return el;
} }
export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document): VoidFunction { export function onLeftClick(
const handler: typeof callback = event => { callback: (e: MouseEvent) => boolean | void,
context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document,
): VoidFunction {
const handler: typeof callback = (event) => {
if (event.button === 0) callback(event); if (event.button === 0) callback(event);
}; };
context.addEventListener('click', handler); context.addEventListener('click', handler);
@ -80,24 +92,19 @@ export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context
export function whenReady(callback: VoidFunction): void { export function whenReady(callback: VoidFunction): void {
if (document.readyState !== 'loading') { if (document.readyState !== 'loading') {
callback(); callback();
} } else {
else {
document.addEventListener('DOMContentLoaded', callback); document.addEventListener('DOMContentLoaded', callback);
} }
} }
export function escapeHtml(html: string): string { export function escapeHtml(html: string): string {
return html.replace(/&/g, '&amp;') return html.replace(/&/g, '&amp;').replace(/>/g, '&gt;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
.replace(/>/g, '&gt;')
.replace(/</g, '&lt;')
.replace(/"/g, '&quot;');
} }
export function escapeCss(css: string): string { export function escapeCss(css: string): string {
return css.replace(/\\/g, '\\\\') return css.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
.replace(/"/g, '\\"');
} }
export function findFirstTextNode<N extends Node>(of: Node): N { export function findFirstTextNode<N extends Node>(of: Node): N {
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0]; return Array.prototype.filter.call(of.childNodes, (el) => el.nodeType === Node.TEXT_NODE)[0];
} }

View file

@ -47,8 +47,7 @@ function drop(event: DragEvent, target: HTMLElement) {
if (event.clientX < detX) { if (event.clientX < detX) {
target.insertAdjacentElement('beforebegin', dragSrcEl); target.insertAdjacentElement('beforebegin', dragSrcEl);
} } else {
else {
target.insertAdjacentElement('afterend', dragSrcEl); target.insertAdjacentElement('afterend', dragSrcEl);
} }
} }
@ -57,7 +56,7 @@ function dragEnd(event: DragEvent, target: HTMLElement) {
clearDragSource(); clearDragSource();
if (target.parentNode) { if (target.parentNode) {
$$('.over', target.parentNode).forEach(t => t.classList.remove('over')); $$('.over', target.parentNode).forEach((t) => t.classList.remove('over'));
} }
} }

View file

@ -3,16 +3,16 @@
import '../../types/ujs'; import '../../types/ujs';
export interface PhilomenaAvailableEventsMap { export interface PhilomenaAvailableEventsMap {
dragstart: DragEvent, dragstart: DragEvent;
dragover: DragEvent, dragover: DragEvent;
dragenter: DragEvent, dragenter: DragEvent;
dragleave: DragEvent, dragleave: DragEvent;
dragend: DragEvent, dragend: DragEvent;
drop: DragEvent, drop: DragEvent;
click: MouseEvent, click: MouseEvent;
submit: Event, submit: Event;
reset: Event, reset: Event;
fetchcomplete: FetchcompleteEvent fetchcomplete: FetchcompleteEvent;
} }
export interface PhilomenaEventElement { export interface PhilomenaEventElement {
@ -20,7 +20,7 @@ export interface PhilomenaEventElement {
type: K, type: K,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any, listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any,
options?: boolean | AddEventListenerOptions | undefined options?: boolean | AddEventListenerOptions | undefined,
): void; ): void;
} }
@ -30,21 +30,25 @@ export function fire<El extends Element, D>(el: El, event: string, detail: D) {
export function on<K extends keyof PhilomenaAvailableEventsMap>( export function on<K extends keyof PhilomenaAvailableEventsMap>(
node: PhilomenaEventElement, node: PhilomenaEventElement,
event: K, selector: string, func: ((e: PhilomenaAvailableEventsMap[K], target: Element) => boolean) event: K,
selector: string,
func: (e: PhilomenaAvailableEventsMap[K], target: Element) => boolean,
) { ) {
delegate(node, event, { [selector]: func }); delegate(node, event, { [selector]: func });
} }
export function leftClick<E extends MouseEvent, Target extends EventTarget>(func: (e: E, t: Target) => void) { export function leftClick<E extends MouseEvent, Target extends EventTarget>(func: (e: E, t: Target) => void) {
return (event: E, target: Target) => { if (event.button === 0) return func(event, target); }; return (event: E, target: Target) => {
if (event.button === 0) return func(event, target);
};
} }
export function delegate<K extends keyof PhilomenaAvailableEventsMap, Target extends Element>( export function delegate<K extends keyof PhilomenaAvailableEventsMap, Target extends Element>(
node: PhilomenaEventElement, node: PhilomenaEventElement,
event: K, event: K,
selectors: Record<string, ((e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean)> selectors: Record<string, (e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean>,
) { ) {
node.addEventListener(event, e => { node.addEventListener(event, (e) => {
for (const selector in selectors) { for (const selector in selectors) {
const evtTarget = e.target as EventTarget | Target | null; const evtTarget = e.target as EventTarget | Target | null;
if (evtTarget && 'closest' in evtTarget && typeof evtTarget.closest === 'function') { if (evtTarget && 'closest' in evtTarget && typeof evtTarget.closest === 'function') {

View file

@ -53,8 +53,7 @@ export function showThumb(img: HTMLDivElement) {
if (uris[size].indexOf('.webm') !== -1) { if (uris[size].indexOf('.webm') !== -1) {
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
overlay.innerHTML = 'WebM'; overlay.innerHTML = 'WebM';
} } else {
else {
overlay.classList.add('hidden'); overlay.classList.add('hidden');
} }
@ -118,7 +117,9 @@ export function spoilerThumb(img: HTMLDivElement, spoilerUri: string, reason: st
switch (window.booru.spoilerType) { switch (window.booru.spoilerType) {
case 'click': case 'click':
img.addEventListener('click', event => { if (showThumb(img)) event.preventDefault(); }); img.addEventListener('click', (event) => {
if (showThumb(img)) event.preventDefault();
});
img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason)); img.addEventListener('mouseleave', () => hideThumb(img, spoilerUri, reason));
break; break;
case 'hover': case 'hover':

View file

@ -5,8 +5,11 @@
// clamp the value between min and max, depending on whether // clamp the value between min and max, depending on whether
// the delta >= 1 or <= 0. // the delta >= 1 or <= 0.
export function lerp(delta: number, from: number, to: number): number { export function lerp(delta: number, from: number, to: number): number {
if (delta >= 1) { return to; } if (delta >= 1) {
else if (delta <= 0) { return from; } return to;
} else if (delta <= 0) {
return from;
}
return from + (to - from) * delta; return from + (to - from) * delta;
} }

View file

@ -100,7 +100,11 @@ export class LocalAutocompleter {
/** /**
* Perform a binary search to fetch all results matching a condition. * Perform a binary search to fetch all results matching a condition.
*/ */
scanResults(getResult: (i: number) => [string, Result], compare: (name: string) => number, results: Record<string, Result>) { scanResults(
getResult: (i: number) => [string, Result],
compare: (name: string) => number,
results: Record<string, Result>,
) {
const unfilter = store.get('unfilter_tag_suggestions'); const unfilter = store.get('unfilter_tag_suggestions');
let min = 0; let min = 0;
@ -109,14 +113,13 @@ export class LocalAutocompleter {
const hiddenTags = window.booru.hiddenTagList; const hiddenTags = window.booru.hiddenTagList;
while (min < max - 1) { while (min < max - 1) {
const med = min + (max - min) / 2 | 0; const med = (min + (max - min) / 2) | 0;
const sortKey = getResult(med)[0]; const sortKey = getResult(med)[0];
if (compare(sortKey) >= 0) { if (compare(sortKey) >= 0) {
// too large, go left // too large, go left
max = med; max = med;
} } else {
else {
// too small, go right // too small, go right
min = med; min = med;
} }
@ -130,7 +133,7 @@ export class LocalAutocompleter {
} }
// Add if not filtering or no associations are filtered // Add if not filtering or no associations are filtered
if (unfilter || hiddenTags.findIndex(ht => result.associations.includes(ht)) === -1) { if (unfilter || hiddenTags.findIndex((ht) => result.associations.includes(ht)) === -1) {
results[result.name] = result; results[result.name] = result;
} }
} }

View file

@ -9,7 +9,7 @@ export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record<stri
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest' 'x-requested-with': 'xmlhttprequest',
}, },
}; };
@ -26,7 +26,7 @@ export function fetchHtml(endpoint: string): Promise<Response> {
credentials: 'same-origin', credentials: 'same-origin',
headers: { headers: {
'x-csrf-token': window.booru.csrfToken, 'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest' 'x-requested-with': 'xmlhttprequest',
}, },
}); });
} }

View file

@ -5,13 +5,11 @@
export const lastUpdatedSuffix = '__lastUpdated'; export const lastUpdatedSuffix = '__lastUpdated';
export default { export default {
set(key: string, value: unknown) { set(key: string, value: unknown) {
try { try {
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
return true; return true;
} } catch {
catch {
return false; return false;
} }
}, },
@ -21,8 +19,7 @@ export default {
if (value === null) return null; if (value === null) return null;
try { try {
return JSON.parse(value); return JSON.parse(value);
} } catch {
catch {
return value as unknown as Value; return value as unknown as Value;
} }
}, },
@ -31,8 +28,7 @@ export default {
try { try {
localStorage.removeItem(key); localStorage.removeItem(key);
return true; return true;
} } catch {
catch {
return false; return false;
} }
}, },
@ -61,5 +57,4 @@ export default {
return lastUpdatedTime === null || Date.now() > lastUpdatedTime; return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
}, },
}; };

View file

@ -30,7 +30,7 @@ function sortTags(hidden: boolean, a: TagData, b: TagData): number {
export function getHiddenTags(): TagData[] { export function getHiddenTags(): TagData[] {
return unique(window.booru.hiddenTagList) return unique(window.booru.hiddenTagList)
.map(tagId => getTag(tagId)) .map((tagId) => getTag(tagId))
.sort(sortTags.bind(null, true)); .sort(sortTags.bind(null, true));
} }
@ -38,8 +38,8 @@ export function getSpoileredTags(): TagData[] {
if (window.booru.spoilerType === 'off') return []; if (window.booru.spoilerType === 'off') return [];
return unique(window.booru.spoileredTagList) return unique(window.booru.spoileredTagList)
.filter(tagId => window.booru.ignoredTagList.indexOf(tagId) === -1) .filter((tagId) => window.booru.ignoredTagList.indexOf(tagId) === -1)
.map(tagId => getTag(tagId)) .map((tagId) => getTag(tagId))
.sort(sortTags.bind(null, false)); .sort(sortTags.bind(null, false));
} }
@ -49,7 +49,7 @@ export function imageHitsTags(img: HTMLElement, matchTags: TagData[]): TagData[]
return []; return [];
} }
const imageTags = JSON.parse(imageTagsString); const imageTags = JSON.parse(imageTagsString);
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1); return matchTags.filter((t) => imageTags.indexOf(t.id) !== -1);
} }
export function imageHitsComplex(img: HTMLElement, matchComplex: AstMatcher) { export function imageHitsComplex(img: HTMLElement, matchComplex: AstMatcher) {
@ -57,11 +57,13 @@ export function imageHitsComplex(img: HTMLElement, matchComplex: AstMatcher) {
} }
export function displayTags(tags: TagData[]): string { export function displayTags(tags: TagData[]): string {
const mainTag = tags[0], otherTags = tags.slice(1); const mainTag = tags[0],
let list = escapeHtml(mainTag.name), extras; otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name),
extras;
if (otherTags.length > 0) { if (otherTags.length > 0) {
extras = otherTags.map(tag => escapeHtml(tag.name)).join(', '); extras = otherTags.map((tag) => escapeHtml(tag.name)).join(', ');
list += `<span title="${extras}">, ${extras}</span>`; list += `<span title="${extras}">, ${extras}</span>`;
} }

View file

@ -36,7 +36,6 @@ import { sizeGraphs } from './graph';
import { setupSliders } from './slider'; import { setupSliders } from './slider';
whenReady(() => { whenReady(() => {
loadBooruData(); loadBooruData();
listenAutocomplete(); listenAutocomplete();
registerEvents(); registerEvents();
@ -67,5 +66,4 @@ whenReady(() => {
imageSourcesCreator(); imageSourcesCreator();
setupSliders(); setupSliders();
sizeGraphs(); sizeGraphs();
}); });

345
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,11 +14,8 @@
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@types/postcss-mixins": "^9.0.5", "@types/postcss-mixins": "^9.0.5",
"@types/web": "^0.0.148", "@types/web": "^0.0.148",
"typescript-eslint": "8.0.0-alpha.39",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9.4.0",
"jest-environment-jsdom": "^29.7.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"postcss-mixins": "^10.0.1", "postcss-mixins": "^10.0.1",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
@ -31,12 +28,16 @@
"@types/chai-dom": "^1.11.3", "@types/chai-dom": "^1.11.3",
"@vitest/coverage-v8": "^1.6.0", "@vitest/coverage-v8": "^1.6.0",
"chai": "^5", "chai": "^5",
"eslint": "^9.4.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vitest": "^0.5.4", "eslint-plugin-vitest": "^0.5.4",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"stylelint": "^16.6.1", "stylelint": "^16.6.1",
"stylelint-config-standard": "^36.0.0", "stylelint-config-standard": "^36.0.0",
"stylelint-prettier": "^5.0.0", "stylelint-prettier": "^5.0.0",
"typescript-eslint": "8.0.0-alpha.39",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"vitest-fetch-mock": "^0.2.2" "vitest-fetch-mock": "^0.2.2"
} }

View file

@ -8,7 +8,7 @@ export function fixEventListeners(t: EventTarget) {
eventListeners = {}; eventListeners = {};
const oldAddEventListener = t.addEventListener; const oldAddEventListener = t.addEventListener;
t.addEventListener = function(type: string, listener: any, options: any): void { t.addEventListener = (type: string, listener: any, options: any): void => {
eventListeners[type] = eventListeners[type] || []; eventListeners[type] = eventListeners[type] || [];
eventListeners[type].push(listener); eventListeners[type].push(listener);
return oldAddEventListener(type, listener, options); return oldAddEventListener(type, listener, options);

View file

@ -2,7 +2,9 @@ import { MockInstance } from 'vitest';
type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem'; type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem';
export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: MockInstance } { export function mockStorage<Keys extends MockStorageKeys>(
options: Pick<Storage, Keys>,
): { [k in `${Keys}Spy`]: MockInstance } {
const getItemSpy = 'getItem' in options ? vi.spyOn(Storage.prototype, 'getItem') : undefined; const getItemSpy = 'getItem' in options ? vi.spyOn(Storage.prototype, 'getItem') : undefined;
const setItemSpy = 'setItem' in options ? vi.spyOn(Storage.prototype, 'setItem') : undefined; const setItemSpy = 'setItem' in options ? vi.spyOn(Storage.prototype, 'setItem') : undefined;
const removeItemSpy = 'removeItem' in options ? vi.spyOn(Storage.prototype, 'removeItem') : undefined; const removeItemSpy = 'removeItem' in options ? vi.spyOn(Storage.prototype, 'removeItem') : undefined;
@ -33,18 +35,18 @@ type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: MockInstance } & {
* Forces the mock storage back to its default (empty) state * Forces the mock storage back to its default (empty) state
* @param value * @param value
*/ */
clearStorage: VoidFunction, clearStorage: VoidFunction;
/** /**
* Forces the mock storage to be in the specific state provided as the parameter * Forces the mock storage to be in the specific state provided as the parameter
* @param value * @param value
*/ */
setStorageValue: (value: Record<string, string>) => void, setStorageValue: (value: Record<string, string>) => void;
/** /**
* Forces the mock storage to throw an error for the duration of the provided function's execution, * Forces the mock storage to throw an error for the duration of the provided function's execution,
* or in case a promise is returned by the function, until that promise is resolved. * or in case a promise is returned by the function, until that promise is resolved.
*/ */
forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void;
} };
export function mockStorageImpl(): MockStorageImplApi { export function mockStorageImpl(): MockStorageImplApi {
let shouldThrow = false; let shouldThrow = false;
@ -66,7 +68,7 @@ export function mockStorageImpl(): MockStorageImplApi {
delete tempStorage[key]; delete tempStorage[key];
}, },
}); });
const forceStorageError: MockStorageImplApi['forceStorageError'] = func => { const forceStorageError: MockStorageImplApi['forceStorageError'] = (func) => {
shouldThrow = true; shouldThrow = true;
const value = func(); const value = func();
if (!(value instanceof Promise)) { if (!(value instanceof Promise)) {
@ -78,7 +80,7 @@ export function mockStorageImpl(): MockStorageImplApi {
shouldThrow = false; shouldThrow = false;
}); });
}; };
const setStorageValue: MockStorageImplApi['setStorageValue'] = value => { const setStorageValue: MockStorageImplApi['setStorageValue'] = (value) => {
tempStorage = value; tempStorage = value;
}; };
const clearStorage = () => setStorageValue({}); const clearStorage = () => setStorageValue({});

View file

@ -21,7 +21,7 @@ window.booru = {
spoileredFilter: matchNone(), spoileredFilter: matchNone(),
interactions: [], interactions: [],
tagsVersion: 5, tagsVersion: 5,
galleryImages: [] galleryImages: [],
}; };
// https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038 // https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038
@ -31,6 +31,7 @@ Object.assign(globalThis, { URL, Blob });
// Prevents an error when calling `form.submit()` directly in // Prevents an error when calling `form.submit()` directly in
// the code that is being tested // the code that is being tested
// eslint-disable-next-line prettier/prettier
HTMLFormElement.prototype.submit = function() { HTMLFormElement.prototype.submit = function() {
fireEvent.submit(this); fireEvent.submit(this);
}; };

View file

@ -2,7 +2,7 @@ export {};
declare global { declare global {
interface FetchcompleteEvent extends CustomEvent<Response> { interface FetchcompleteEvent extends CustomEvent<Response> {
target: HTMLElement, target: HTMLElement;
} }
interface GlobalEventHandlersEventMap { interface GlobalEventHandlersEventMap {

View file

@ -11,18 +11,16 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
const isDev = command !== 'build' && mode !== 'test'; const isDev = command !== 'build' && mode !== 'test';
const targets = new Map(); const targets = new Map();
fs.readdirSync(path.resolve(__dirname, 'css/themes/')).forEach(name => { fs.readdirSync(path.resolve(__dirname, 'css/themes/')).forEach((name) => {
const m = name.match(/([-a-z]+).css/); const m = name.match(/([-a-z]+).css/);
if (m) if (m) targets.set(`css/${m[1]}`, `./css/themes/${m[1]}.css`);
targets.set(`css/${m[1]}`, `./css/themes/${m[1]}.css`);
}); });
fs.readdirSync(path.resolve(__dirname, 'css/options/')).forEach(name => { fs.readdirSync(path.resolve(__dirname, 'css/options/')).forEach((name) => {
const m = name.match(/([-a-z]+).css/); const m = name.match(/([-a-z]+).css/);
if (m) if (m) targets.set(`css/options/${m[1]}`, `./css/options/${m[1]}.css`);
targets.set(`css/options/${m[1]}`, `./css/options/${m[1]}.css`);
}); });
return { return {
@ -37,8 +35,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
common: path.resolve(__dirname, 'css/common/'), common: path.resolve(__dirname, 'css/common/'),
views: path.resolve(__dirname, 'css/views/'), views: path.resolve(__dirname, 'css/views/'),
elements: path.resolve(__dirname, 'css/elements/'), elements: path.resolve(__dirname, 'css/elements/'),
themes: path.resolve(__dirname, 'css/themes/') themes: path.resolve(__dirname, 'css/themes/'),
} },
}, },
build: { build: {
target: ['es2016', 'chrome67', 'firefox62', 'edge18', 'safari12'], target: ['es2016', 'chrome67', 'firefox62', 'edge18', 'safari12'],
@ -51,19 +49,19 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
input: { input: {
'js/app': './js/app.ts', 'js/app': './js/app.ts',
'css/application': './css/application.css', 'css/application': './css/application.css',
...Object.fromEntries(targets) ...Object.fromEntries(targets),
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: '[name].js',
chunkFileNames: '[name].js', chunkFileNames: '[name].js',
assetFileNames: '[name][extname]' assetFileNames: '[name][extname]',
} },
} },
}, },
css: { css: {
postcss: { postcss: {
plugins: [postcssMixins(), postcssSimpleVars(), postcssRelativeColor(), autoprefixer] plugins: [postcssMixins(), postcssSimpleVars(), postcssRelativeColor(), autoprefixer],
} },
}, },
test: { test: {
globals: true, globals: true,
@ -74,11 +72,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
coverage: { coverage: {
reporter: ['text', 'html'], reporter: ['text', 'html'],
include: ['js/**/*.{js,ts}'], include: ['js/**/*.{js,ts}'],
exclude: [ exclude: ['node_modules/', '.*\\.test\\.ts$', '.*\\.d\\.ts$'],
'node_modules/',
'.*\\.test\\.ts$',
'.*\\.d\\.ts$',
],
thresholds: { thresholds: {
statements: 0, statements: 0,
branches: 0, branches: 0,
@ -90,8 +84,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
functions: 100, functions: 100,
lines: 100, lines: 100,
}, },
} },
} },
} },
}; };
}); });