mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-31 16:55:29 +02:00
Merge pull request #162 from philomena-dev/ts-utils
Convert utilities to TypeScript
This commit is contained in:
commit
ef64c6da41
29 changed files with 1619 additions and 8924 deletions
|
@ -16,7 +16,7 @@ export default {
|
||||||
functions: 0,
|
functions: 0,
|
||||||
lines: 0,
|
lines: 0,
|
||||||
},
|
},
|
||||||
'./js/utils/**/*.js': {
|
'./js/utils/**/*.ts': {
|
||||||
statements: 100,
|
statements: 100,
|
||||||
branches: 100,
|
branches: 100,
|
||||||
functions: 100,
|
functions: 100,
|
||||||
|
|
|
@ -2,12 +2,25 @@ import { $ } from './utils/dom';
|
||||||
import parseSearch from './match_query';
|
import parseSearch from './match_query';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
|
|
||||||
/* Store a tag locally, marking the retrieval time */
|
/**
|
||||||
|
* Store a tag locally, marking the retrieval time
|
||||||
|
* @param {TagData} tagData
|
||||||
|
*/
|
||||||
function persistTag(tagData) {
|
function persistTag(tagData) {
|
||||||
tagData.fetchedAt = new Date().getTime() / 1000;
|
/**
|
||||||
store.set(`bor_tags_${tagData.id}`, tagData);
|
* @type {TagData}
|
||||||
|
*/
|
||||||
|
const persistData = {
|
||||||
|
...tagData,
|
||||||
|
fetchedAt: new Date().getTime() / 1000,
|
||||||
|
};
|
||||||
|
store.set(`bor_tags_${tagData.id}`, persistData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TagData} tag
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
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);
|
||||||
|
@ -21,11 +34,31 @@ function clearTags() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Returns a single tag, or a dummy tag object if we don't know about it yet */
|
/**
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {value is TagData}
|
||||||
|
*/
|
||||||
|
function isValidStoredTag(value) {
|
||||||
|
if ('id' in value && 'name' in value && 'images' in value && 'spoiler_image_uri' in value) {
|
||||||
|
return typeof value.id === 'number'
|
||||||
|
&& typeof value.name === 'string'
|
||||||
|
&& typeof value.images === 'number'
|
||||||
|
&& (value.spoiler_image_uri === null || typeof value.spoiler_image_uri === 'string')
|
||||||
|
&& (value.fetchedAt === null || typeof value.fetchedAt === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a single tag, or a dummy tag object if we don't know about it yet
|
||||||
|
* @param {number} tagId
|
||||||
|
* @returns {TagData}
|
||||||
|
*/
|
||||||
function getTag(tagId) {
|
function getTag(tagId) {
|
||||||
const stored = store.get(`bor_tags_${tagId}`);
|
const stored = store.get(`bor_tags_${tagId}`);
|
||||||
|
|
||||||
if (stored) {
|
if (isValidStoredTag(stored)) {
|
||||||
return stored;
|
return stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,10 +67,14 @@ function getTag(tagId) {
|
||||||
name: '(unknown tag)',
|
name: '(unknown tag)',
|
||||||
images: 0,
|
images: 0,
|
||||||
spoiler_image_uri: null,
|
spoiler_image_uri: null,
|
||||||
|
fetchedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fetches lots of tags in batches and stores them locally */
|
/**
|
||||||
|
* Fetches lots of tags in batches and stores them locally
|
||||||
|
* @param {number[]} tagIds
|
||||||
|
*/
|
||||||
function fetchAndPersistTags(tagIds) {
|
function fetchAndPersistTags(tagIds) {
|
||||||
if (!tagIds.length) return;
|
if (!tagIds.length) return;
|
||||||
|
|
||||||
|
@ -50,7 +87,10 @@ function fetchAndPersistTags(tagIds) {
|
||||||
.then(() => fetchAndPersistTags(remaining));
|
.then(() => fetchAndPersistTags(remaining));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Figure out which tags in the list we don't know about */
|
/**
|
||||||
|
* Figure out which tags in the list we don't know about
|
||||||
|
* @param {number[]} tagIds
|
||||||
|
*/
|
||||||
function fetchNewOrStaleTags(tagIds) {
|
function fetchNewOrStaleTags(tagIds) {
|
||||||
const fetchIds = [];
|
const fetchIds = [];
|
||||||
|
|
||||||
|
|
|
@ -874,7 +874,4 @@ SearchAST.prototype.dumpTree = function() {
|
||||||
return retStrArr.join('\n');
|
return retStrArr.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force module handling for Jest, can be removed after TypeScript migration
|
|
||||||
export {};
|
|
||||||
|
|
||||||
export default parseSearch;
|
export default parseSearch;
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { $$ } from './utils/dom';
|
import { $$ } from './utils/dom';
|
||||||
import store from './utils/store';
|
import store from './utils/store';
|
||||||
import { initTagDropdown} from './tags';
|
import { initTagDropdown } from './tags';
|
||||||
import { setupTagsInput, reloadTagsInput } from './tagsinput';
|
import { setupTagsInput, reloadTagsInput } from './tagsinput';
|
||||||
|
|
||||||
function tagInputButtons({target}) {
|
function tagInputButtons({target}) {
|
||||||
|
|
|
@ -44,6 +44,7 @@ describe('Array Utilities', () => {
|
||||||
moveElement(input, 2, 1);
|
moveElement(input, 2, 1);
|
||||||
expect(input).toEqual(['a', 'c', 'b', 'd']);
|
expect(input).toEqual(['a', 'c', 'b', 'd']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with ascending index parameters', () => {
|
it('should work with ascending index parameters', () => {
|
||||||
const input = ['a', 'b', 'c', 'd'];
|
const input = ['a', 'b', 'c', 'd'];
|
||||||
moveElement(input, 1, 2);
|
moveElement(input, 1, 2);
|
||||||
|
@ -88,50 +89,48 @@ describe('Array Utilities', () => {
|
||||||
['', null, false, uniqueValue, mockObject, Infinity, undefined]
|
['', null, false, uniqueValue, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return true for matching up to the first array\'s length', () => {
|
describe('negative cases', () => {
|
||||||
|
it('should NOT return true for matching only up to the first array\'s length', () => {
|
||||||
// Numbers
|
// Numbers
|
||||||
expect(arraysEqual([0], [0, 1])).toBe(true);
|
expect(arraysEqual([0], [0, 1])).toBe(false);
|
||||||
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
|
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
|
||||||
|
|
||||||
// Strings
|
// Strings
|
||||||
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
|
expect(arraysEqual(['a'], ['a', 'b'])).toBe(false);
|
||||||
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
|
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(false);
|
||||||
|
|
||||||
// Object by reference
|
// Object by reference
|
||||||
const uniqueValue1 = Symbol('item1');
|
const uniqueValue1 = Symbol('item1');
|
||||||
const uniqueValue2 = Symbol('item2');
|
const uniqueValue2 = Symbol('item2');
|
||||||
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
|
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(false);
|
||||||
|
|
||||||
// Mixed parameters
|
// Mixed parameters
|
||||||
const mockObject = { value: Math.random() };
|
const mockObject = { value: Math.random() };
|
||||||
expect(arraysEqual(
|
expect(arraysEqual(
|
||||||
[''],
|
[''],
|
||||||
['', null, false, mockObject, Infinity, undefined]
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(false);
|
||||||
expect(arraysEqual(
|
expect(arraysEqual(
|
||||||
['', null],
|
['', null],
|
||||||
['', null, false, mockObject, Infinity, undefined]
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(false);
|
||||||
expect(arraysEqual(
|
expect(arraysEqual(
|
||||||
['', null, false],
|
['', null, false],
|
||||||
['', null, false, mockObject, Infinity, undefined]
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(false);
|
||||||
expect(arraysEqual(
|
expect(arraysEqual(
|
||||||
['', null, false, mockObject],
|
['', null, false, mockObject],
|
||||||
['', null, false, mockObject, Infinity, undefined]
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(false);
|
||||||
expect(arraysEqual(
|
expect(arraysEqual(
|
||||||
['', null, false, mockObject, Infinity],
|
['', null, false, mockObject, Infinity],
|
||||||
['', null, false, mockObject, Infinity, undefined]
|
['', null, false, mockObject, Infinity, undefined]
|
||||||
)).toBe(true);
|
)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('negative cases', () => {
|
it('should return false for arrays of different length', () => {
|
||||||
// FIXME This case should be handled
|
|
||||||
// eslint-disable-next-line jest/no-disabled-tests
|
|
||||||
it.skip('should return false for arrays of different length', () => {
|
|
||||||
// Numbers
|
// Numbers
|
||||||
expect(arraysEqual([], [0])).toBe(false);
|
expect(arraysEqual([], [0])).toBe(false);
|
||||||
expect(arraysEqual([0], [])).toBe(false);
|
expect(arraysEqual([0], [])).toBe(false);
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
escapeCss,
|
escapeCss,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
hideEl,
|
hideEl,
|
||||||
insertBefore,
|
|
||||||
makeEl,
|
makeEl,
|
||||||
onLeftClick,
|
onLeftClick,
|
||||||
removeEl,
|
removeEl,
|
||||||
|
@ -245,9 +244,9 @@ describe('DOM Utilities', () => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error if element has no parent', () => {
|
it('should NOT throw error if element has no parent', () => {
|
||||||
const detachedElement = document.createElement('div');
|
const detachedElement = document.createElement('div');
|
||||||
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
|
expect(() => removeEl(detachedElement)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call the native removeElement method on parent', () => {
|
it('should call the native removeElement method on parent', () => {
|
||||||
|
@ -297,50 +296,17 @@ describe('DOM Utilities', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('insertBefore', () => {
|
|
||||||
it('should insert the new element before the existing element', () => {
|
|
||||||
const mockParent = document.createElement('p');
|
|
||||||
const mockExisingElement = document.createElement('span');
|
|
||||||
mockParent.appendChild(mockExisingElement);
|
|
||||||
const mockNewElement = document.createElement('strong');
|
|
||||||
|
|
||||||
insertBefore(mockExisingElement, mockNewElement);
|
|
||||||
|
|
||||||
expect(mockParent.children).toHaveLength(2);
|
|
||||||
expect(mockParent.children[0].tagName).toBe('STRONG');
|
|
||||||
expect(mockParent.children[1].tagName).toBe('SPAN');
|
|
||||||
});
|
|
||||||
it('should insert between two elements', () => {
|
|
||||||
const mockParent = document.createElement('p');
|
|
||||||
const mockFirstExisingElement = document.createElement('span');
|
|
||||||
const mockSecondExisingElement = document.createElement('em');
|
|
||||||
mockParent.appendChild(mockFirstExisingElement);
|
|
||||||
mockParent.appendChild(mockSecondExisingElement);
|
|
||||||
const mockNewElement = document.createElement('strong');
|
|
||||||
|
|
||||||
insertBefore(mockSecondExisingElement, mockNewElement);
|
|
||||||
|
|
||||||
expect(mockParent.children).toHaveLength(3);
|
|
||||||
expect(mockParent.children[0].tagName).toBe('SPAN');
|
|
||||||
expect(mockParent.children[1].tagName).toBe('STRONG');
|
|
||||||
expect(mockParent.children[2].tagName).toBe('EM');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail if there is no parent', () => {
|
|
||||||
const mockParent = document.createElement('p');
|
|
||||||
const mockNewElement = document.createElement('em');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
insertBefore(mockParent, mockNewElement);
|
|
||||||
}).toThrow(/propert(y|ies).*null/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('onLeftClick', () => {
|
describe('onLeftClick', () => {
|
||||||
|
let cleanup: VoidFunction | undefined;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (cleanup) cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
it('should call callback on left click', () => {
|
it('should call callback on left click', () => {
|
||||||
const mockCallback = jest.fn();
|
const mockCallback = jest.fn();
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
onLeftClick(mockCallback, element as unknown as Document);
|
cleanup = onLeftClick(mockCallback, element as unknown as Document);
|
||||||
|
|
||||||
fireEvent.click(element, { button: 0 });
|
fireEvent.click(element, { button: 0 });
|
||||||
|
|
||||||
|
@ -350,7 +316,7 @@ describe('DOM Utilities', () => {
|
||||||
it('should NOT call callback on non-left click', () => {
|
it('should NOT call callback on non-left click', () => {
|
||||||
const mockCallback = jest.fn();
|
const mockCallback = jest.fn();
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
onLeftClick(mockCallback, element as unknown as Document);
|
cleanup = onLeftClick(mockCallback, element as unknown as Document);
|
||||||
|
|
||||||
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
|
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
|
||||||
fireEvent.click(element, { button: mockButton });
|
fireEvent.click(element, { button: mockButton });
|
||||||
|
@ -360,12 +326,29 @@ describe('DOM Utilities', () => {
|
||||||
|
|
||||||
it('should add click event listener to the document by default', () => {
|
it('should add click event listener to the document by default', () => {
|
||||||
const mockCallback = jest.fn();
|
const mockCallback = jest.fn();
|
||||||
onLeftClick(mockCallback);
|
cleanup = onLeftClick(mockCallback);
|
||||||
|
|
||||||
fireEvent.click(document.body);
|
fireEvent.click(document.body);
|
||||||
|
|
||||||
expect(mockCallback).toHaveBeenCalledTimes(1);
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return a cleanup function that removes the listener', () => {
|
||||||
|
const mockCallback = jest.fn();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const localCleanup = onLeftClick(mockCallback, element as unknown as Document);
|
||||||
|
|
||||||
|
fireEvent.click(element, { button: 0 });
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Remove the listener
|
||||||
|
localCleanup();
|
||||||
|
|
||||||
|
fireEvent.click(element, { button: 0 });
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('whenReady', () => {
|
describe('whenReady', () => {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { initDraggables } from '../draggable';
|
import { clearDragSource, initDraggables } from '../draggable';
|
||||||
import { fireEvent } from '@testing-library/dom';
|
import { fireEvent } from '@testing-library/dom';
|
||||||
import { getRandomArrayItem } from '../../../test/randomness';
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
|
|
||||||
|
@ -115,6 +115,14 @@ describe('Draggable Utilities', () => {
|
||||||
|
|
||||||
expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
|
expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not throw if the event has no dataTransfer property', () => {
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockEvent = createDragEvent('dragstart');
|
||||||
|
delete (mockEvent as Record<keyof typeof mockEvent, unknown>).dataTransfer;
|
||||||
|
expect(() => fireEvent(mockDraggable, mockEvent)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dragOver', () => {
|
describe('dragOver', () => {
|
||||||
|
@ -228,6 +236,20 @@ describe('Draggable Utilities', () => {
|
||||||
boundingBoxSpy.mockRestore();
|
boundingBoxSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not throw if drag source element is not set', () => {
|
||||||
|
clearDragSource();
|
||||||
|
initDraggables();
|
||||||
|
|
||||||
|
const mockSecondDraggable = createDraggableElement();
|
||||||
|
mockDragContainer.appendChild(mockSecondDraggable);
|
||||||
|
|
||||||
|
const mockDropEvent = createDragEvent('drop');
|
||||||
|
fireEvent(mockDraggable, mockDropEvent);
|
||||||
|
|
||||||
|
expect(() => fireEvent(mockDraggable, mockDropEvent)).not.toThrow();
|
||||||
|
expect(mockDropEvent.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dragEnd', () => {
|
describe('dragEnd', () => {
|
||||||
|
@ -269,7 +291,7 @@ describe('Draggable Utilities', () => {
|
||||||
initDraggables();
|
initDraggables();
|
||||||
|
|
||||||
const mockEvent = createDragEvent('dragstart');
|
const mockEvent = createDragEvent('dragstart');
|
||||||
const documentClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
|
const draggableClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fireEvent(mockDraggable, mockEvent);
|
fireEvent(mockDraggable, mockEvent);
|
||||||
|
@ -277,7 +299,7 @@ describe('Draggable Utilities', () => {
|
||||||
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
documentClosestSpy.mockRestore();
|
draggableClosestSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { delegate, fire, leftClick, on } from '../events';
|
import { delegate, fire, leftClick, on, PhilomenaAvailableEventsMap } from '../events';
|
||||||
import { getRandomArrayItem } from '../../../test/randomness';
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
import { fireEvent } from '@testing-library/dom';
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
|
||||||
describe('Event utils', () => {
|
describe('Event utils', () => {
|
||||||
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave']);
|
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave'] as (keyof PhilomenaAvailableEventsMap)[]);
|
||||||
|
|
||||||
describe('fire', () => {
|
describe('fire', () => {
|
||||||
it('should call the native dispatchEvent method on the element', () => {
|
it('should call the native dispatchEvent method on the element', () => {
|
||||||
|
|
|
@ -54,6 +54,22 @@ describe('Image utils', () => {
|
||||||
mockSpoilerOverlay,
|
mockSpoilerOverlay,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
const imageFilteredClass = 'image-filtered';
|
||||||
|
const imageShowClass = 'image-show';
|
||||||
|
const spoilerPendingClass = 'spoiler-pending';
|
||||||
|
const createImageFilteredElement = (mockElement: HTMLDivElement) => {
|
||||||
|
const mockFilteredImageElement = document.createElement('div');
|
||||||
|
mockFilteredImageElement.classList.add(imageFilteredClass);
|
||||||
|
mockElement.appendChild(mockFilteredImageElement);
|
||||||
|
return { mockFilteredImageElement };
|
||||||
|
};
|
||||||
|
const createImageShowElement = (mockElement: HTMLDivElement) => {
|
||||||
|
const mockShowElement = document.createElement('div');
|
||||||
|
mockShowElement.classList.add(imageShowClass);
|
||||||
|
mockShowElement.classList.add(hiddenClass);
|
||||||
|
mockElement.appendChild(mockShowElement);
|
||||||
|
return { mockShowElement };
|
||||||
|
};
|
||||||
|
|
||||||
describe('showThumb', () => {
|
describe('showThumb', () => {
|
||||||
let mockServeHidpiValue: string | null = null;
|
let mockServeHidpiValue: string | null = null;
|
||||||
|
@ -146,6 +162,26 @@ describe('Image utils', () => {
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
['data-size', 'data-uris'].forEach(missingAttributeName => {
|
||||||
|
it(`should return early if the ${missingAttributeName} attribute is missing`, () => {
|
||||||
|
const { mockElement } = createMockElements({
|
||||||
|
extension: 'webm',
|
||||||
|
});
|
||||||
|
const jsonParseSpy = jest.spyOn(JSON, 'parse');
|
||||||
|
|
||||||
|
mockElement.removeAttribute(missingAttributeName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(jsonParseSpy).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
jsonParseSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return early if there is no video element', () => {
|
it('should return early if there is no video element', () => {
|
||||||
const { mockElement, mockVideo, playSpy } = createMockElements({
|
const { mockElement, mockVideo, playSpy } = createMockElements({
|
||||||
extension: 'webm',
|
extension: 'webm',
|
||||||
|
@ -304,24 +340,21 @@ describe('Image utils', () => {
|
||||||
const result = showThumb(mockElement);
|
const result = showThumb(mockElement);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return false if overlay is missing', () => {
|
||||||
|
const { mockElement, mockSpoilerOverlay } = createMockElementWithPicture('jpg');
|
||||||
|
mockSpoilerOverlay.parentNode?.removeChild(mockSpoilerOverlay);
|
||||||
|
const result = showThumb(mockElement);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('showBlock', () => {
|
describe('showBlock', () => {
|
||||||
const imageFilteredClass = 'image-filtered';
|
|
||||||
const imageShowClass = 'image-show';
|
|
||||||
const spoilerPendingClass = 'spoiler-pending';
|
|
||||||
|
|
||||||
it('should hide the filtered image element and show the image', () => {
|
it('should hide the filtered image element and show the image', () => {
|
||||||
const mockElement = document.createElement('div');
|
const mockElement = document.createElement('div');
|
||||||
|
|
||||||
const mockFilteredImageElement = document.createElement('div');
|
const { mockFilteredImageElement } = createImageFilteredElement(mockElement);
|
||||||
mockFilteredImageElement.classList.add(imageFilteredClass);
|
const { mockShowElement } = createImageShowElement(mockElement);
|
||||||
mockElement.appendChild(mockFilteredImageElement);
|
|
||||||
|
|
||||||
const mockShowElement = document.createElement('div');
|
|
||||||
mockShowElement.classList.add(imageShowClass);
|
|
||||||
mockShowElement.classList.add(hiddenClass);
|
|
||||||
mockElement.appendChild(mockShowElement);
|
|
||||||
|
|
||||||
showBlock(mockElement);
|
showBlock(mockElement);
|
||||||
|
|
||||||
|
@ -329,6 +362,18 @@ describe('Image utils', () => {
|
||||||
expect(mockShowElement).not.toHaveClass(hiddenClass);
|
expect(mockShowElement).not.toHaveClass(hiddenClass);
|
||||||
expect(mockShowElement).toHaveClass(spoilerPendingClass);
|
expect(mockShowElement).toHaveClass(spoilerPendingClass);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not throw if image-filtered element is missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
createImageShowElement(mockElement);
|
||||||
|
expect(() => showBlock(mockElement)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw if image-show element is missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
createImageFilteredElement(mockElement);
|
||||||
|
expect(() => showBlock(mockElement)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hideThumb', () => {
|
describe('hideThumb', () => {
|
||||||
|
@ -528,9 +573,7 @@ describe('Image utils', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('spoilerBlock', () => {
|
describe('spoilerBlock', () => {
|
||||||
const imageFilteredClass = 'image-filtered';
|
|
||||||
const filterExplanationClass = 'filter-explanation';
|
const filterExplanationClass = 'filter-explanation';
|
||||||
const imageShowClass = 'image-show';
|
|
||||||
const createFilteredImageElement = () => {
|
const createFilteredImageElement = () => {
|
||||||
const mockImageFiltered = document.createElement('div');
|
const mockImageFiltered = document.createElement('div');
|
||||||
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
|
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
|
||||||
|
@ -541,22 +584,31 @@ describe('Image utils', () => {
|
||||||
|
|
||||||
return { mockImageFiltered, mockImage };
|
return { mockImageFiltered, mockImage };
|
||||||
};
|
};
|
||||||
|
const createMockElement = (appendImageShow = true, appendImageFiltered = true) => {
|
||||||
it('should do nothing if image element is missing', () => {
|
|
||||||
const mockElement = document.createElement('div');
|
const mockElement = document.createElement('div');
|
||||||
|
const { mockImageFiltered, mockImage } = createFilteredImageElement();
|
||||||
|
if (appendImageFiltered) mockElement.appendChild(mockImageFiltered);
|
||||||
|
const mockExplanation = document.createElement('span');
|
||||||
|
mockExplanation.classList.add(filterExplanationClass);
|
||||||
|
mockElement.appendChild(mockExplanation);
|
||||||
|
|
||||||
|
const mockImageShow = document.createElement('div');
|
||||||
|
mockImageShow.classList.add(imageShowClass);
|
||||||
|
if (appendImageShow) mockElement.appendChild(mockImageShow);
|
||||||
|
|
||||||
|
return { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not throw if image element is missing', () => {
|
||||||
|
const mockElement = document.createElement('div');
|
||||||
|
const { mockImageFiltered, mockImage } = createFilteredImageElement();
|
||||||
|
mockImage.parentNode?.removeChild(mockImage);
|
||||||
|
mockElement.appendChild(mockImageFiltered);
|
||||||
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
|
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the elements with the parameters and set classes if image element is found', () => {
|
it('should update the elements with the parameters and set classes if image element is found', () => {
|
||||||
const mockElement = document.createElement('div');
|
const { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered } = createMockElement();
|
||||||
const { mockImageFiltered, mockImage } = createFilteredImageElement();
|
|
||||||
mockElement.appendChild(mockImageFiltered);
|
|
||||||
const mockExplanation = document.createElement('span');
|
|
||||||
mockExplanation.classList.add(filterExplanationClass);
|
|
||||||
mockElement.appendChild(mockExplanation);
|
|
||||||
const mockImageShow = document.createElement('div');
|
|
||||||
mockImageShow.classList.add(imageShowClass);
|
|
||||||
mockElement.appendChild(mockImageShow);
|
|
||||||
|
|
||||||
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
|
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||||
|
|
||||||
|
@ -565,5 +617,15 @@ describe('Image utils', () => {
|
||||||
expect(mockImageShow).toHaveClass(hiddenClass);
|
expect(mockImageShow).toHaveClass(hiddenClass);
|
||||||
expect(mockImageFiltered).not.toHaveClass(hiddenClass);
|
expect(mockImageFiltered).not.toHaveClass(hiddenClass);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not throw if image-filtered element is missing', () => {
|
||||||
|
const { mockElement } = createMockElement(true, false);
|
||||||
|
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw if image-show element is missing', () => {
|
||||||
|
const { mockElement } = createMockElement(false, true);
|
||||||
|
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,48 +1,45 @@
|
||||||
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags } from '../tag';
|
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags, TagData } from '../tag';
|
||||||
import { mockStorage } from '../../../test/mock-storage';
|
import { mockStorage } from '../../../test/mock-storage';
|
||||||
import { getRandomArrayItem } from '../../../test/randomness';
|
import { getRandomArrayItem } from '../../../test/randomness';
|
||||||
import parseSearch from '../../match_query';
|
import parseSearch from '../../match_query';
|
||||||
|
|
||||||
// TODO Move to source file when rewriting in TypeScript
|
|
||||||
interface StorageTagInfo {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
images: number;
|
|
||||||
spoiler_image_uri: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Tag utilities', () => {
|
describe('Tag utilities', () => {
|
||||||
const tagStorageKeyPrefix = 'bor_tags_';
|
const tagStorageKeyPrefix = 'bor_tags_';
|
||||||
const mockTagInfo: Record<string, StorageTagInfo> = {
|
const mockTagInfo: Record<string, TagData> = {
|
||||||
1: {
|
1: {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'safe',
|
name: 'safe',
|
||||||
images: 69,
|
images: 69,
|
||||||
spoiler_image_uri: null,
|
spoiler_image_uri: null,
|
||||||
|
fetchedAt: null,
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'fox',
|
name: 'fox',
|
||||||
images: 1,
|
images: 1,
|
||||||
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
|
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
|
||||||
|
fetchedAt: null,
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'paw pads',
|
name: 'paw pads',
|
||||||
images: 42,
|
images: 42,
|
||||||
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
|
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
|
||||||
|
fetchedAt: null,
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'whiskers',
|
name: 'whiskers',
|
||||||
images: 42,
|
images: 42,
|
||||||
spoiler_image_uri: null,
|
spoiler_image_uri: null,
|
||||||
|
fetchedAt: null,
|
||||||
},
|
},
|
||||||
5: {
|
5: {
|
||||||
id: 5,
|
id: 5,
|
||||||
name: 'lilo & stitch',
|
name: 'lilo & stitch',
|
||||||
images: 6,
|
images: 6,
|
||||||
spoiler_image_uri: null,
|
spoiler_image_uri: null,
|
||||||
|
fetchedAt: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
|
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
|
||||||
|
@ -147,6 +144,12 @@ describe('Tag utilities', () => {
|
||||||
mockTagInfo[4],
|
mockTagInfo[4],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return empty array if data attribute is missing', () => {
|
||||||
|
const mockImage = new Image();
|
||||||
|
const result = imageHitsTags(mockImage, []);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('imageHitsComplex', () => {
|
describe('imageHitsComplex', () => {
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
// http://stackoverflow.com/a/5306832/1726690
|
|
||||||
export function moveElement(array, from, to) {
|
|
||||||
array.splice(to, 0, array.splice(from, 1)[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function arraysEqual(array1, array2) {
|
|
||||||
for (let i = 0; i < array1.length; ++i) {
|
|
||||||
if (array1[i] !== array2[i]) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
10
assets/js/utils/array.ts
Normal file
10
assets/js/utils/array.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// http://stackoverflow.com/a/5306832/1726690
|
||||||
|
export function moveElement<Items>(array: Items[], from: number, to: number): void {
|
||||||
|
array.splice(to, 0, array.splice(from, 1)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arraysEqual(array1: unknown[], array2: unknown[]): boolean {
|
||||||
|
if (array1.length !== array2.length) return false;
|
||||||
|
|
||||||
|
return array1.every((item, index) => item === array2[index]);
|
||||||
|
}
|
|
@ -1,74 +0,0 @@
|
||||||
/**
|
|
||||||
* DOM Utils
|
|
||||||
*/
|
|
||||||
|
|
||||||
function $(selector, context = document) { // Get the first matching element
|
|
||||||
const element = context.querySelector(selector);
|
|
||||||
|
|
||||||
return element || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function $$(selector, context = document) { // Get every matching element as an array
|
|
||||||
const elements = context.querySelectorAll(selector);
|
|
||||||
|
|
||||||
return [].slice.call(elements);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEl(...elements) {
|
|
||||||
[].concat(...elements).forEach(el => el.classList.remove('hidden'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideEl(...elements) {
|
|
||||||
[].concat(...elements).forEach(el => el.classList.add('hidden'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleEl(...elements) {
|
|
||||||
[].concat(...elements).forEach(el => el.classList.toggle('hidden'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearEl(...elements) {
|
|
||||||
[].concat(...elements).forEach(el => { while (el.firstChild) el.removeChild(el.firstChild); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeEl(...elements) {
|
|
||||||
[].concat(...elements).forEach(el => el.parentNode.removeChild(el));
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEl(tag, attr = {}) {
|
|
||||||
const el = document.createElement(tag);
|
|
||||||
for (const prop in attr) el[prop] = attr[prop];
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertBefore(existingElement, newElement) {
|
|
||||||
existingElement.parentNode.insertBefore(newElement, existingElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLeftClick(callback, context = document) {
|
|
||||||
context.addEventListener('click', event => {
|
|
||||||
if (event.button === 0) callback(event);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function whenReady(callback) { // Execute a function when the DOM is ready
|
|
||||||
if (document.readyState !== 'loading') callback();
|
|
||||||
else document.addEventListener('DOMContentLoaded', callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(html) {
|
|
||||||
return html.replace(/&/g, '&')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeCss(css) {
|
|
||||||
return css.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/"/g, '\\"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFirstTextNode(of) {
|
|
||||||
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export { $, $$, showEl, hideEl, toggleEl, clearEl, removeEl, makeEl, insertBefore, onLeftClick, whenReady, escapeHtml, escapeCss, findFirstTextNode };
|
|
89
assets/js/utils/dom.ts
Normal file
89
assets/js/utils/dom.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
// DOM Utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first matching element
|
||||||
|
*/
|
||||||
|
export function $<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelector'> = document): E | null {
|
||||||
|
return context.querySelector<E>(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get every matching element as an array
|
||||||
|
*/
|
||||||
|
export function $$<E extends Element = Element>(selector: string, context: Pick<Document, 'querySelectorAll'> = document): E[] {
|
||||||
|
const elements = context.querySelectorAll<E>(selector);
|
||||||
|
|
||||||
|
return [...elements];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
|
||||||
|
([] as E[]).concat(...elements).forEach(el => el.classList.remove('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
|
||||||
|
([] as E[]).concat(...elements).forEach(el => el.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
|
||||||
|
([] as E[]).concat(...elements).forEach(el => el.classList.toggle('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
|
||||||
|
([] as E[]).concat(...elements).forEach(el => {
|
||||||
|
while (el.firstChild) el.removeChild(el.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEl<E extends HTMLElement>(...elements: E[] | ConcatArray<E>[]) {
|
||||||
|
([] 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] {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
if (attr) {
|
||||||
|
for (const prop in attr) {
|
||||||
|
const newValue = attr[prop];
|
||||||
|
if (typeof newValue !== 'undefined') {
|
||||||
|
el[prop] = newValue as Exclude<typeof newValue, undefined>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLeftClick(callback: (e: MouseEvent) => boolean | void, context: Pick<GlobalEventHandlers, 'addEventListener' | 'removeEventListener'> = document): VoidFunction {
|
||||||
|
const handler: typeof callback = event => {
|
||||||
|
if (event.button === 0) callback(event);
|
||||||
|
};
|
||||||
|
context.addEventListener('click', handler);
|
||||||
|
|
||||||
|
return () => context.removeEventListener('click', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function when the DOM is ready
|
||||||
|
*/
|
||||||
|
export function whenReady(callback: VoidFunction): void {
|
||||||
|
if (document.readyState !== 'loading') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
document.addEventListener('DOMContentLoaded', callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(html: string): string {
|
||||||
|
return html.replace(/&/g, '&')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeCss(css: string): string {
|
||||||
|
return css.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFirstTextNode<N extends Node>(of: Node): N {
|
||||||
|
return Array.prototype.filter.call(of.childNodes, el => el.nodeType === Node.TEXT_NODE)[0];
|
||||||
|
}
|
|
@ -1,70 +0,0 @@
|
||||||
import { $$ } from './dom';
|
|
||||||
|
|
||||||
let dragSrcEl;
|
|
||||||
|
|
||||||
function dragStart(event, target) {
|
|
||||||
target.classList.add('dragging');
|
|
||||||
dragSrcEl = target;
|
|
||||||
|
|
||||||
if (event.dataTransfer.items.length === 0) {
|
|
||||||
event.dataTransfer.setData('text/plain', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragOver(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragEnter(event, target) {
|
|
||||||
target.classList.add('over');
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragLeave(event, target) {
|
|
||||||
target.classList.remove('over');
|
|
||||||
}
|
|
||||||
|
|
||||||
function drop(event, target) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
dragSrcEl.classList.remove('dragging');
|
|
||||||
|
|
||||||
if (dragSrcEl === target) return;
|
|
||||||
|
|
||||||
// divide the target element into two sets of coordinates
|
|
||||||
// and determine how to act based on the relative mouse positioin
|
|
||||||
const bbox = target.getBoundingClientRect();
|
|
||||||
const detX = bbox.left + (bbox.width / 2);
|
|
||||||
|
|
||||||
if (event.clientX < detX) {
|
|
||||||
target.insertAdjacentElement('beforebegin', dragSrcEl);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
target.insertAdjacentElement('afterend', dragSrcEl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dragEnd(event, target) {
|
|
||||||
dragSrcEl.classList.remove('dragging');
|
|
||||||
|
|
||||||
$$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function wrapper(func) {
|
|
||||||
return function(event) {
|
|
||||||
if (!event.target.closest) return;
|
|
||||||
const target = event.target.closest('.drag-container [draggable]');
|
|
||||||
if (target) func(event, target);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initDraggables() {
|
|
||||||
document.addEventListener('dragstart', wrapper(dragStart));
|
|
||||||
document.addEventListener('dragover', wrapper(dragOver));
|
|
||||||
document.addEventListener('dragenter', wrapper(dragEnter));
|
|
||||||
document.addEventListener('dragleave', wrapper(dragLeave));
|
|
||||||
document.addEventListener('dragend', wrapper(dragEnd));
|
|
||||||
document.addEventListener('drop', wrapper(drop));
|
|
||||||
}
|
|
79
assets/js/utils/draggable.ts
Normal file
79
assets/js/utils/draggable.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { $$ } from './dom';
|
||||||
|
import { delegate } from './events';
|
||||||
|
|
||||||
|
let dragSrcEl: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function dragStart(event: DragEvent, target: HTMLElement) {
|
||||||
|
target.classList.add('dragging');
|
||||||
|
dragSrcEl = target;
|
||||||
|
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
|
||||||
|
if (event.dataTransfer.items.length === 0) {
|
||||||
|
event.dataTransfer.setData('text/plain', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnter(event: DragEvent, target: HTMLElement) {
|
||||||
|
target.classList.add('over');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragLeave(event: DragEvent, target: HTMLElement) {
|
||||||
|
target.classList.remove('over');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(event: DragEvent, target: HTMLElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!dragSrcEl) return;
|
||||||
|
|
||||||
|
dragSrcEl.classList.remove('dragging');
|
||||||
|
|
||||||
|
if (dragSrcEl === target) return;
|
||||||
|
|
||||||
|
// divide the target element into two sets of coordinates
|
||||||
|
// and determine how to act based on the relative mouse position
|
||||||
|
const bbox = target.getBoundingClientRect();
|
||||||
|
const detX = bbox.left + bbox.width / 2;
|
||||||
|
|
||||||
|
if (event.clientX < detX) {
|
||||||
|
target.insertAdjacentElement('beforebegin', dragSrcEl);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
target.insertAdjacentElement('afterend', dragSrcEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd(event: DragEvent, target: HTMLElement) {
|
||||||
|
clearDragSource();
|
||||||
|
|
||||||
|
if (target.parentNode) {
|
||||||
|
$$('.over', target.parentNode).forEach(t => t.classList.remove('over'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initDraggables() {
|
||||||
|
const draggableSelector = '.drag-container [draggable]';
|
||||||
|
delegate(document, 'dragstart', { [draggableSelector]: dragStart});
|
||||||
|
delegate(document, 'dragover', { [draggableSelector]: dragOver});
|
||||||
|
delegate(document, 'dragenter', { [draggableSelector]: dragEnter});
|
||||||
|
delegate(document, 'dragleave', { [draggableSelector]: dragLeave});
|
||||||
|
delegate(document, 'dragend', { [draggableSelector]: dragEnd});
|
||||||
|
delegate(document, 'drop', { [draggableSelector]: drop});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDragSource() {
|
||||||
|
if (!dragSrcEl) return;
|
||||||
|
|
||||||
|
dragSrcEl.classList.remove('dragging');
|
||||||
|
dragSrcEl = null;
|
||||||
|
}
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* DOM events
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function fire(el, event, detail) {
|
|
||||||
el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function on(node, event, selector, func) {
|
|
||||||
delegate(node, event, { [selector]: func });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function leftClick(func) {
|
|
||||||
return (event, target) => { if (event.button === 0) return func(event, target); };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function delegate(node, event, selectors) {
|
|
||||||
node.addEventListener(event, e => {
|
|
||||||
for (const selector in selectors) {
|
|
||||||
const target = e.target.closest(selector);
|
|
||||||
if (target && selectors[selector](e, target) === false) break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
53
assets/js/utils/events.ts
Normal file
53
assets/js/utils/events.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
// DOM events
|
||||||
|
|
||||||
|
export interface PhilomenaAvailableEventsMap {
|
||||||
|
dragstart: DragEvent,
|
||||||
|
dragover: DragEvent,
|
||||||
|
dragenter: DragEvent,
|
||||||
|
dragleave: DragEvent,
|
||||||
|
dragend: DragEvent,
|
||||||
|
drop: DragEvent,
|
||||||
|
click: MouseEvent,
|
||||||
|
submit: Event,
|
||||||
|
reset: Event
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhilomenaEventElement {
|
||||||
|
addEventListener<K extends keyof PhilomenaAvailableEventsMap>(
|
||||||
|
type: K,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
listener: (this: Document | HTMLElement, ev: PhilomenaAvailableEventsMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions | undefined
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fire<El extends Element, D>(el: El, event: string, detail: D) {
|
||||||
|
el.dispatchEvent(new CustomEvent<D>(event, { detail, bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function on<K extends keyof PhilomenaAvailableEventsMap>(
|
||||||
|
node: PhilomenaEventElement,
|
||||||
|
event: K, selector: string, func: ((e: PhilomenaAvailableEventsMap[K], target: Element) => boolean)
|
||||||
|
) {
|
||||||
|
delegate(node, event, { [selector]: func });
|
||||||
|
}
|
||||||
|
|
||||||
|
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); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delegate<K extends keyof PhilomenaAvailableEventsMap, Target extends Element>(
|
||||||
|
node: PhilomenaEventElement,
|
||||||
|
event: K,
|
||||||
|
selectors: Record<string, ((e: PhilomenaAvailableEventsMap[K], target: Target) => void | boolean)>
|
||||||
|
) {
|
||||||
|
node.addEventListener(event, e => {
|
||||||
|
for (const selector in selectors) {
|
||||||
|
const evtTarget = e.target as EventTarget | Target | null;
|
||||||
|
if (evtTarget && 'closest' in evtTarget && typeof evtTarget.closest === 'function') {
|
||||||
|
const target = evtTarget.closest(selector) as Target;
|
||||||
|
if (target && selectors[selector](e, target) === false) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
import { clearEl } from './dom';
|
import { clearEl } from './dom';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
function showVideoThumb(img) {
|
function showVideoThumb(img: HTMLDivElement, size: string, uris: Record<string, string>) {
|
||||||
const size = img.dataset.size;
|
|
||||||
const uris = JSON.parse(img.dataset.uris);
|
|
||||||
const thumbUri = uris[size];
|
const thumbUri = uris[size];
|
||||||
|
|
||||||
const vidEl = img.querySelector('video');
|
const vidEl = img.querySelector('video');
|
||||||
|
@ -21,18 +19,22 @@ function showVideoThumb(img) {
|
||||||
vidEl.classList.remove('hidden');
|
vidEl.classList.remove('hidden');
|
||||||
vidEl.play();
|
vidEl.play();
|
||||||
|
|
||||||
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
|
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
||||||
|
if (overlay) overlay.classList.add('hidden');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showThumb(img) {
|
export function showThumb(img: HTMLDivElement) {
|
||||||
const size = img.dataset.size;
|
const size = img.dataset.size;
|
||||||
const uris = JSON.parse(img.dataset.uris);
|
const urisString = img.dataset.uris;
|
||||||
|
if (!size || !urisString) return false;
|
||||||
|
|
||||||
|
const uris: Record<string, string> = JSON.parse(urisString);
|
||||||
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
const thumbUri = uris[size].replace(/webm$/, 'gif');
|
||||||
|
|
||||||
const picEl = img.querySelector('picture');
|
const picEl = img.querySelector('picture');
|
||||||
if (!picEl) return showVideoThumb(img);
|
if (!picEl) return showVideoThumb(img, size, uris);
|
||||||
|
|
||||||
const imgEl = picEl.querySelector('img');
|
const imgEl = picEl.querySelector('img');
|
||||||
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
|
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
|
||||||
|
@ -45,26 +47,30 @@ export function showThumb(img) {
|
||||||
}
|
}
|
||||||
|
|
||||||
imgEl.src = thumbUri;
|
imgEl.src = thumbUri;
|
||||||
|
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
||||||
|
if (!overlay) return false;
|
||||||
|
|
||||||
if (uris[size].indexOf('.webm') !== -1) {
|
if (uris[size].indexOf('.webm') !== -1) {
|
||||||
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
overlay.innerHTML = 'WebM';
|
overlay.innerHTML = 'WebM';
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
|
overlay.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showBlock(img) {
|
export function showBlock(img: HTMLDivElement) {
|
||||||
img.querySelector('.image-filtered').classList.add('hidden');
|
img.querySelector('.image-filtered')?.classList.add('hidden');
|
||||||
const imageShowClasses = img.querySelector('.image-show').classList;
|
const imageShowClasses = img.querySelector('.image-show')?.classList;
|
||||||
imageShowClasses.remove('hidden');
|
if (imageShowClasses) {
|
||||||
imageShowClasses.add('spoiler-pending');
|
imageShowClasses.remove('hidden');
|
||||||
|
imageShowClasses.add('spoiler-pending');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideVideoThumb(img, spoilerUri, reason) {
|
function hideVideoThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||||
const vidEl = img.querySelector('video');
|
const vidEl = img.querySelector('video');
|
||||||
if (!vidEl) return;
|
if (!vidEl) return;
|
||||||
|
|
||||||
|
@ -74,15 +80,17 @@ function hideVideoThumb(img, spoilerUri, reason) {
|
||||||
|
|
||||||
imgEl.classList.remove('hidden');
|
imgEl.classList.remove('hidden');
|
||||||
imgEl.src = spoilerUri;
|
imgEl.src = spoilerUri;
|
||||||
imgOverlay.innerHTML = reason;
|
if (imgOverlay) {
|
||||||
imgOverlay.classList.remove('hidden');
|
imgOverlay.innerHTML = reason;
|
||||||
|
imgOverlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
clearEl(vidEl);
|
clearEl(vidEl);
|
||||||
vidEl.classList.add('hidden');
|
vidEl.classList.add('hidden');
|
||||||
vidEl.pause();
|
vidEl.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideThumb(img, spoilerUri, reason) {
|
export function hideThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||||
const picEl = img.querySelector('picture');
|
const picEl = img.querySelector('picture');
|
||||||
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
||||||
|
|
||||||
|
@ -93,11 +101,13 @@ export function hideThumb(img, spoilerUri, reason) {
|
||||||
|
|
||||||
imgEl.srcset = '';
|
imgEl.srcset = '';
|
||||||
imgEl.src = spoilerUri;
|
imgEl.src = spoilerUri;
|
||||||
imgOverlay.innerHTML = reason;
|
if (imgOverlay) {
|
||||||
imgOverlay.classList.remove('hidden');
|
imgOverlay.innerHTML = reason;
|
||||||
|
imgOverlay.classList.remove('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spoilerThumb(img, spoilerUri, reason) {
|
export function spoilerThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||||
hideThumb(img, spoilerUri, reason);
|
hideThumb(img, spoilerUri, reason);
|
||||||
|
|
||||||
switch (window.booru.spoilerType) {
|
switch (window.booru.spoilerType) {
|
||||||
|
@ -114,15 +124,19 @@ export function spoilerThumb(img, spoilerUri, reason) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spoilerBlock(img, spoilerUri, reason) {
|
export function spoilerBlock(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||||
const imgEl = img.querySelector('.image-filtered img');
|
const imgFiltered = img.querySelector('.image-filtered');
|
||||||
const imgReason = img.querySelector('.filter-explanation');
|
const imgEl = imgFiltered?.querySelector<HTMLImageElement>('img');
|
||||||
|
|
||||||
if (!imgEl) return;
|
if (!imgEl) return;
|
||||||
|
|
||||||
imgEl.src = spoilerUri;
|
const imgReason = img.querySelector<HTMLElement>('.filter-explanation');
|
||||||
imgReason.innerHTML = reason;
|
const imageShow = img.querySelector('.image-show');
|
||||||
|
|
||||||
img.querySelector('.image-show').classList.add('hidden');
|
imgEl.src = spoilerUri;
|
||||||
img.querySelector('.image-filtered').classList.remove('hidden');
|
if (imgReason) {
|
||||||
|
imgReason.innerHTML = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageShow?.classList.add('hidden');
|
||||||
|
if (imgFiltered) imgFiltered.classList.remove('hidden');
|
||||||
}
|
}
|
|
@ -1,31 +1,23 @@
|
||||||
// Client-side tag completion.
|
// Client-side tag completion.
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
/**
|
interface Result {
|
||||||
* @typedef {object} Result
|
name: string;
|
||||||
* @property {string} name
|
imageCount: number;
|
||||||
* @property {number} imageCount
|
associations: number[];
|
||||||
* @property {number[]} associations
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compare two strings, C-style.
|
* Compare two strings, C-style.
|
||||||
*
|
|
||||||
* @param {string} a
|
|
||||||
* @param {string} b
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
*/
|
||||||
function strcmp(a, b) {
|
function strcmp(a: string, b: string): number {
|
||||||
return a < b ? -1 : Number(a > b);
|
return a < b ? -1 : Number(a > b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the name of a tag without any namespace component.
|
* Returns the name of a tag without any namespace component.
|
||||||
*
|
|
||||||
* @param {string} s
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
function nameInNamespace(s) {
|
function nameInNamespace(s: string): string {
|
||||||
const v = s.split(':', 2);
|
const v = s.split(':', 2);
|
||||||
|
|
||||||
if (v.length === 2) return v[1];
|
if (v.length === 2) return v[1];
|
||||||
|
@ -39,25 +31,24 @@ function nameInNamespace(s) {
|
||||||
* the JS heap and speed up the execution of the search.
|
* the JS heap and speed up the execution of the search.
|
||||||
*/
|
*/
|
||||||
export class LocalAutocompleter {
|
export class LocalAutocompleter {
|
||||||
|
private data: Uint8Array;
|
||||||
|
private view: DataView;
|
||||||
|
private decoder: TextDecoder;
|
||||||
|
private numTags: number;
|
||||||
|
private referenceStart: number;
|
||||||
|
private secondaryStart: number;
|
||||||
|
private formatVersion: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a new local autocompleter.
|
* Build a new local autocompleter.
|
||||||
*
|
|
||||||
* @param {ArrayBuffer} backingStore
|
|
||||||
*/
|
*/
|
||||||
constructor(backingStore) {
|
constructor(backingStore: ArrayBuffer) {
|
||||||
/** @type {Uint8Array} */
|
|
||||||
this.data = new Uint8Array(backingStore);
|
this.data = new Uint8Array(backingStore);
|
||||||
/** @type {DataView} */
|
|
||||||
this.view = new DataView(backingStore);
|
this.view = new DataView(backingStore);
|
||||||
/** @type {TextDecoder} */
|
|
||||||
this.decoder = new TextDecoder();
|
this.decoder = new TextDecoder();
|
||||||
/** @type {number} */
|
|
||||||
this.numTags = this.view.getUint32(backingStore.byteLength - 4, true);
|
this.numTags = this.view.getUint32(backingStore.byteLength - 4, true);
|
||||||
/** @type {number} */
|
|
||||||
this.referenceStart = this.view.getUint32(backingStore.byteLength - 8, true);
|
this.referenceStart = this.view.getUint32(backingStore.byteLength - 8, true);
|
||||||
/** @type {number} */
|
|
||||||
this.secondaryStart = this.referenceStart + 8 * this.numTags;
|
this.secondaryStart = this.referenceStart + 8 * this.numTags;
|
||||||
/** @type {number} */
|
|
||||||
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
|
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
|
||||||
|
|
||||||
if (this.formatVersion !== 2) {
|
if (this.formatVersion !== 2) {
|
||||||
|
@ -67,11 +58,8 @@ export class LocalAutocompleter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a tag's name and its associations given a byte location inside the file.
|
* Get a tag's name and its associations given a byte location inside the file.
|
||||||
*
|
|
||||||
* @param {number} location
|
|
||||||
* @returns {[string, number[]]}
|
|
||||||
*/
|
*/
|
||||||
getTagFromLocation(location) {
|
getTagFromLocation(location: number): [string, number[]] {
|
||||||
const nameLength = this.view.getUint8(location);
|
const nameLength = this.view.getUint8(location);
|
||||||
const assnLength = this.view.getUint8(location + 1 + nameLength);
|
const assnLength = this.view.getUint8(location + 1 + nameLength);
|
||||||
|
|
||||||
|
@ -88,11 +76,8 @@ export class LocalAutocompleter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a Result object as the ith tag inside the file.
|
* Get a Result object as the ith tag inside the file.
|
||||||
*
|
|
||||||
* @param {number} i
|
|
||||||
* @returns {[string, Result]}
|
|
||||||
*/
|
*/
|
||||||
getResultAt(i) {
|
getResultAt(i: number): [string, Result] {
|
||||||
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
|
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
|
||||||
const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true);
|
const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true);
|
||||||
const [ name, associations ] = this.getTagFromLocation(nameLocation);
|
const [ name, associations ] = this.getTagFromLocation(nameLocation);
|
||||||
|
@ -107,23 +92,16 @@ export class LocalAutocompleter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a Result object as the ith tag inside the file, secondary ordering.
|
* Get a Result object as the ith tag inside the file, secondary ordering.
|
||||||
*
|
|
||||||
* @param {number} i
|
|
||||||
* @returns {[string, Result]}
|
|
||||||
*/
|
*/
|
||||||
getSecondaryResultAt(i) {
|
getSecondaryResultAt(i: number): [string, Result] {
|
||||||
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
|
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
|
||||||
return this.getResultAt(referenceIndex);
|
return this.getResultAt(referenceIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a binary search to fetch all results matching a condition.
|
* Perform a binary search to fetch all results matching a condition.
|
||||||
*
|
|
||||||
* @param {(i: number) => [string, Result]} getResult
|
|
||||||
* @param {(name: string) => number} compare
|
|
||||||
* @param {{[key: string]: Result}} results
|
|
||||||
*/
|
*/
|
||||||
scanResults(getResult, compare, results) {
|
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;
|
||||||
|
@ -132,7 +110,7 @@ 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) {
|
||||||
|
@ -161,25 +139,20 @@ export class LocalAutocompleter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the top k results by image count which match the given string prefix.
|
* Find the top k results by image count which match the given string prefix.
|
||||||
*
|
|
||||||
* @param {string} prefix
|
|
||||||
* @param {number} k
|
|
||||||
* @returns {Result[]}
|
|
||||||
*/
|
*/
|
||||||
topK(prefix, k) {
|
topK(prefix: string, k: number): Result[] {
|
||||||
/** @type {{[key: string]: Result}} */
|
const results: Record<string, Result> = {};
|
||||||
const results = {};
|
|
||||||
|
|
||||||
if (prefix === '') {
|
if (prefix === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find normally, in full name-sorted order
|
// Find normally, in full name-sorted order
|
||||||
const prefixMatch = (/** @type {string} */ name) => strcmp(name.slice(0, prefix.length), prefix);
|
const prefixMatch = (name: string) => strcmp(name.slice(0, prefix.length), prefix);
|
||||||
this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
|
this.scanResults(this.getResultAt.bind(this), prefixMatch, results);
|
||||||
|
|
||||||
// Find in secondary order
|
// Find in secondary order
|
||||||
const namespaceMatch = (/** @type {string} */ name) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
|
const namespaceMatch = (name: string) => strcmp(nameInNamespace(name).slice(0, prefix.length), prefix);
|
||||||
this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results);
|
this.scanResults(this.getSecondaryResultAt.bind(this), namespaceMatch, results);
|
||||||
|
|
||||||
// Sort results by image count
|
// Sort results by image count
|
|
@ -1,7 +1,9 @@
|
||||||
// Request Utils
|
// Request Utils
|
||||||
|
|
||||||
export function fetchJson(verb, endpoint, body) {
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
|
||||||
const data = {
|
|
||||||
|
export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record<string, unknown>): Promise<Response> {
|
||||||
|
const data: RequestInit = {
|
||||||
method: verb,
|
method: verb,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -19,7 +21,7 @@ export function fetchJson(verb, endpoint, body) {
|
||||||
return fetch(endpoint, data);
|
return fetch(endpoint, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchHtml(endpoint) {
|
export function fetchHtml(endpoint: string): Promise<Response> {
|
||||||
return fetch(endpoint, {
|
return fetch(endpoint, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -29,7 +31,7 @@ export function fetchHtml(endpoint) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleError(response) {
|
export function handleError(response: Response): Response {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Received error from server');
|
throw new Error('Received error from server');
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@ export const lastUpdatedSuffix = '__lastUpdated';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
||||||
set(key, value) {
|
set(key: string, value: unknown) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
return true;
|
return true;
|
||||||
|
@ -16,17 +16,18 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
get(key) {
|
get<Value = unknown>(key: string): Value | null {
|
||||||
const value = localStorage.getItem(key);
|
const value = localStorage.getItem(key);
|
||||||
|
if (value === null) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(value);
|
return JSON.parse(value);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
return value;
|
return value as unknown as Value;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(key) {
|
remove(key: string) {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
return true;
|
return true;
|
||||||
|
@ -37,8 +38,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Watch changes to a specified key - returns value on change
|
// Watch changes to a specified key - returns value on change
|
||||||
watch(key, callback) {
|
watch(key: string, callback: (value: unknown) => void) {
|
||||||
const handler = event => {
|
const handler = (event: StorageEvent) => {
|
||||||
if (event.key === key) callback(this.get(key));
|
if (event.key === key) callback(this.get(key));
|
||||||
};
|
};
|
||||||
window.addEventListener('storage', handler);
|
window.addEventListener('storage', handler);
|
||||||
|
@ -46,7 +47,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
// set() with an additional key containing the current time + expiration time
|
// set() with an additional key containing the current time + expiration time
|
||||||
setWithExpireTime(key, value, maxAge) {
|
setWithExpireTime(key: string, value: unknown, maxAge: number) {
|
||||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||||
const lastUpdatedTime = Date.now() + maxAge;
|
const lastUpdatedTime = Date.now() + maxAge;
|
||||||
|
|
||||||
|
@ -54,11 +55,11 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Whether the value of a key set with setWithExpireTime() has expired
|
// Whether the value of a key set with setWithExpireTime() has expired
|
||||||
hasExpired(key) {
|
hasExpired(key: string) {
|
||||||
const lastUpdatedKey = key + lastUpdatedSuffix;
|
const lastUpdatedKey = key + lastUpdatedSuffix;
|
||||||
const lastUpdatedTime = this.get(lastUpdatedKey);
|
const lastUpdatedTime = this.get<number>(lastUpdatedKey);
|
||||||
|
|
||||||
return Date.now() > lastUpdatedTime;
|
return lastUpdatedTime === null || Date.now() > lastUpdatedTime;
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
|
@ -1,11 +1,19 @@
|
||||||
import { escapeHtml } from './dom';
|
import { escapeHtml } from './dom';
|
||||||
import { getTag } from '../booru';
|
import { getTag } from '../booru';
|
||||||
|
|
||||||
function unique(array) {
|
export interface TagData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
images: number;
|
||||||
|
spoiler_image_uri: string | null;
|
||||||
|
fetchedAt: null | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique<Item>(array: Item[]): Item[] {
|
||||||
return array.filter((a, b, c) => c.indexOf(a) === b);
|
return array.filter((a, b, c) => c.indexOf(a) === b);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortTags(hidden, a, b) {
|
function sortTags(hidden: boolean, a: TagData, b: TagData): number {
|
||||||
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
|
// If both tags have a spoiler image, sort by images count desc (hidden) or asc (spoilered)
|
||||||
if (a.spoiler_image_uri && b.spoiler_image_uri) {
|
if (a.spoiler_image_uri && b.spoiler_image_uri) {
|
||||||
return hidden ? b.images - a.images : a.images - b.images;
|
return hidden ? b.images - a.images : a.images - b.images;
|
||||||
|
@ -34,16 +42,20 @@ export function getSpoileredTags() {
|
||||||
.sort(sortTags.bind(null, false));
|
.sort(sortTags.bind(null, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function imageHitsTags(img, matchTags) {
|
export function imageHitsTags(img: HTMLImageElement, matchTags: TagData[]): TagData[] {
|
||||||
const imageTags = JSON.parse(img.dataset.imageTags);
|
const imageTagsString = img.dataset.imageTags;
|
||||||
|
if (typeof imageTagsString === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
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, matchComplex) {
|
export function imageHitsComplex(img: HTMLImageElement, matchComplex: { hitsImage: (img: HTMLImageElement) => boolean }) {
|
||||||
return matchComplex.hitsImage(img);
|
return matchComplex.hitsImage(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function displayTags(tags) {
|
export function displayTags(tags: TagData[]): string {
|
||||||
const mainTag = tags[0], otherTags = tags.slice(1);
|
const mainTag = tags[0], otherTags = tags.slice(1);
|
||||||
let list = escapeHtml(mainTag.name), extras;
|
let list = escapeHtml(mainTag.name), extras;
|
||||||
|
|
9582
assets/package-lock.json
generated
9582
assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -9,7 +9,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||||
|
"@rollup/plugin-multi-entry": "^6.0.0",
|
||||||
"@rollup/plugin-typescript": "^11.0.0",
|
"@rollup/plugin-typescript": "^11.0.0",
|
||||||
|
"@rollup/plugin-virtual": "^3.0.1",
|
||||||
"@types/web": "^0.0.91",
|
"@types/web": "^0.0.91",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
||||||
"@typescript-eslint/parser": "^5.52.0",
|
"@typescript-eslint/parser": "^5.52.0",
|
||||||
|
@ -33,10 +35,7 @@
|
||||||
"postcss-scss": "^4.0.6",
|
"postcss-scss": "^4.0.6",
|
||||||
"postcss-url": "^10.1.3",
|
"postcss-url": "^10.1.3",
|
||||||
"rollup": "^2.57.0",
|
"rollup": "^2.57.0",
|
||||||
"rollup-plugin-buble": "^0.19.8",
|
|
||||||
"rollup-plugin-includepaths": "^0.2.4",
|
"rollup-plugin-includepaths": "^0.2.4",
|
||||||
"rollup-plugin-multi-entry": "^2.1.0",
|
|
||||||
"rollup-plugin-virtual": "^1.0.1",
|
|
||||||
"sass": "^1.58.3",
|
"sass": "^1.58.3",
|
||||||
"sass-loader": "^13.2.0",
|
"sass-loader": "^13.2.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
@ -56,6 +55,6 @@
|
||||||
"eslint-plugin-jest-dom": "^4.0.3",
|
"eslint-plugin-jest-dom": "^4.0.3",
|
||||||
"jest": "^29.4.3",
|
"jest": "^29.4.3",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
"ts-jest": "^29.0.5"
|
"ts-jest": "^29.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,4 +20,5 @@ window.booru = {
|
||||||
watchedTagList: [],
|
watchedTagList: [],
|
||||||
hiddenFilter: blankFilter,
|
hiddenFilter: blankFilter,
|
||||||
spoileredFilter: blankFilter,
|
spoileredFilter: blankFilter,
|
||||||
|
tagsVersion: 5
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "Node",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2018",
|
"ES2018",
|
||||||
"DOM"
|
"DOM"
|
||||||
|
|
1
assets/types/booru-object.d.ts
vendored
1
assets/types/booru-object.d.ts
vendored
|
@ -51,6 +51,7 @@ interface BooruObject {
|
||||||
* @type {import('../js/match_query.js').SearchAST}
|
* @type {import('../js/match_query.js').SearchAST}
|
||||||
*/
|
*/
|
||||||
spoileredFilter: unknown;
|
spoileredFilter: unknown;
|
||||||
|
tagsVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
|
@ -9,8 +9,7 @@ import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
|
||||||
import ESLintPlugin from 'eslint-webpack-plugin';
|
import ESLintPlugin from 'eslint-webpack-plugin';
|
||||||
import autoprefixer from 'autoprefixer';
|
import autoprefixer from 'autoprefixer';
|
||||||
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
|
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
|
||||||
import rollupPluginMultiEntry from 'rollup-plugin-multi-entry';
|
import rollupPluginMultiEntry from '@rollup/plugin-multi-entry';
|
||||||
import rollupPluginBuble from 'rollup-plugin-buble';
|
|
||||||
import rollupPluginTypescript from '@rollup/plugin-typescript';
|
import rollupPluginTypescript from '@rollup/plugin-typescript';
|
||||||
|
|
||||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||||
|
@ -18,7 +17,6 @@ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const includePaths = rollupPluginIncludepaths();
|
const includePaths = rollupPluginIncludepaths();
|
||||||
const multiEntry = rollupPluginMultiEntry();
|
const multiEntry = rollupPluginMultiEntry();
|
||||||
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
|
|
||||||
const typescript = rollupPluginTypescript();
|
const typescript = rollupPluginTypescript();
|
||||||
|
|
||||||
let plugins = [
|
let plugins = [
|
||||||
|
@ -105,7 +103,6 @@ export default {
|
||||||
loader: 'webpack-rollup-loader',
|
loader: 'webpack-rollup-loader',
|
||||||
options: {
|
options: {
|
||||||
plugins: [
|
plugins: [
|
||||||
buble,
|
|
||||||
includePaths,
|
includePaths,
|
||||||
multiEntry,
|
multiEntry,
|
||||||
typescript,
|
typescript,
|
||||||
|
|
Loading…
Add table
Reference in a new issue