mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-27 13:47:58 +01: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,
|
||||
lines: 0,
|
||||
},
|
||||
'./js/utils/**/*.js': {
|
||||
'./js/utils/**/*.ts': {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
|
|
|
@ -2,12 +2,25 @@ import { $ } from './utils/dom';
|
|||
import parseSearch from './match_query';
|
||||
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) {
|
||||
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) {
|
||||
const now = new Date().getTime() / 1000;
|
||||
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) {
|
||||
const stored = store.get(`bor_tags_${tagId}`);
|
||||
|
||||
if (stored) {
|
||||
if (isValidStoredTag(stored)) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
|
@ -34,10 +67,14 @@ function getTag(tagId) {
|
|||
name: '(unknown tag)',
|
||||
images: 0,
|
||||
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) {
|
||||
if (!tagIds.length) return;
|
||||
|
||||
|
@ -50,7 +87,10 @@ function fetchAndPersistTags(tagIds) {
|
|||
.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) {
|
||||
const fetchIds = [];
|
||||
|
||||
|
|
|
@ -874,7 +874,4 @@ SearchAST.prototype.dumpTree = function() {
|
|||
return retStrArr.join('\n');
|
||||
};
|
||||
|
||||
// Force module handling for Jest, can be removed after TypeScript migration
|
||||
export {};
|
||||
|
||||
export default parseSearch;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { $$ } from './utils/dom';
|
||||
import store from './utils/store';
|
||||
import { initTagDropdown} from './tags';
|
||||
import { initTagDropdown } from './tags';
|
||||
import { setupTagsInput, reloadTagsInput } from './tagsinput';
|
||||
|
||||
function tagInputButtons({target}) {
|
||||
|
|
|
@ -44,6 +44,7 @@ describe('Array Utilities', () => {
|
|||
moveElement(input, 2, 1);
|
||||
expect(input).toEqual(['a', 'c', 'b', 'd']);
|
||||
});
|
||||
|
||||
it('should work with ascending index parameters', () => {
|
||||
const input = ['a', 'b', 'c', 'd'];
|
||||
moveElement(input, 1, 2);
|
||||
|
@ -88,50 +89,48 @@ describe('Array Utilities', () => {
|
|||
['', null, false, uniqueValue, mockObject, Infinity, undefined]
|
||||
)).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
|
||||
expect(arraysEqual([0], [0, 1])).toBe(true);
|
||||
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
|
||||
expect(arraysEqual([0], [0, 1])).toBe(false);
|
||||
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(false);
|
||||
|
||||
// Strings
|
||||
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
|
||||
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
|
||||
expect(arraysEqual(['a'], ['a', 'b'])).toBe(false);
|
||||
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(false);
|
||||
|
||||
// Object by reference
|
||||
const uniqueValue1 = Symbol('item1');
|
||||
const uniqueValue2 = Symbol('item2');
|
||||
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
|
||||
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(false);
|
||||
|
||||
// Mixed parameters
|
||||
const mockObject = { value: Math.random() };
|
||||
expect(arraysEqual(
|
||||
[''],
|
||||
['', null, false, mockObject, Infinity, undefined]
|
||||
)).toBe(true);
|
||||
)).toBe(false);
|
||||
expect(arraysEqual(
|
||||
['', null],
|
||||
['', null, false, mockObject, Infinity, undefined]
|
||||
)).toBe(true);
|
||||
)).toBe(false);
|
||||
expect(arraysEqual(
|
||||
['', null, false],
|
||||
['', null, false, mockObject, Infinity, undefined]
|
||||
)).toBe(true);
|
||||
)).toBe(false);
|
||||
expect(arraysEqual(
|
||||
['', null, false, mockObject],
|
||||
['', null, false, mockObject, Infinity, undefined]
|
||||
)).toBe(true);
|
||||
)).toBe(false);
|
||||
expect(arraysEqual(
|
||||
['', null, false, mockObject, Infinity],
|
||||
['', null, false, mockObject, Infinity, undefined]
|
||||
)).toBe(true);
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative cases', () => {
|
||||
// FIXME This case should be handled
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('should return false for arrays of different length', () => {
|
||||
it('should return false for arrays of different length', () => {
|
||||
// Numbers
|
||||
expect(arraysEqual([], [0])).toBe(false);
|
||||
expect(arraysEqual([0], [])).toBe(false);
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
escapeCss,
|
||||
escapeHtml,
|
||||
hideEl,
|
||||
insertBefore,
|
||||
makeEl,
|
||||
onLeftClick,
|
||||
removeEl,
|
||||
|
@ -245,9 +244,9 @@ describe('DOM Utilities', () => {
|
|||
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');
|
||||
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
|
||||
expect(() => removeEl(detachedElement)).not.toThrow();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
let cleanup: VoidFunction | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (cleanup) cleanup();
|
||||
});
|
||||
|
||||
it('should call callback on left click', () => {
|
||||
const mockCallback = jest.fn();
|
||||
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 });
|
||||
|
||||
|
@ -350,7 +316,7 @@ describe('DOM Utilities', () => {
|
|||
it('should NOT call callback on non-left click', () => {
|
||||
const mockCallback = jest.fn();
|
||||
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]);
|
||||
fireEvent.click(element, { button: mockButton });
|
||||
|
@ -360,12 +326,29 @@ describe('DOM Utilities', () => {
|
|||
|
||||
it('should add click event listener to the document by default', () => {
|
||||
const mockCallback = jest.fn();
|
||||
onLeftClick(mockCallback);
|
||||
cleanup = onLeftClick(mockCallback);
|
||||
|
||||
fireEvent.click(document.body);
|
||||
|
||||
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', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { initDraggables } from '../draggable';
|
||||
import { clearDragSource, initDraggables } from '../draggable';
|
||||
import { fireEvent } from '@testing-library/dom';
|
||||
import { getRandomArrayItem } from '../../../test/randomness';
|
||||
|
||||
|
@ -115,6 +115,14 @@ describe('Draggable Utilities', () => {
|
|||
|
||||
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', () => {
|
||||
|
@ -228,6 +236,20 @@ describe('Draggable Utilities', () => {
|
|||
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', () => {
|
||||
|
@ -269,7 +291,7 @@ describe('Draggable Utilities', () => {
|
|||
initDraggables();
|
||||
|
||||
const mockEvent = createDragEvent('dragstart');
|
||||
const documentClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
|
||||
const draggableClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
|
||||
|
||||
try {
|
||||
fireEvent(mockDraggable, mockEvent);
|
||||
|
@ -277,7 +299,7 @@ describe('Draggable Utilities', () => {
|
|||
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
|
||||
}
|
||||
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 { fireEvent } from '@testing-library/dom';
|
||||
|
||||
describe('Event utils', () => {
|
||||
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave']);
|
||||
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave'] as (keyof PhilomenaAvailableEventsMap)[]);
|
||||
|
||||
describe('fire', () => {
|
||||
it('should call the native dispatchEvent method on the element', () => {
|
||||
|
|
|
@ -54,6 +54,22 @@ describe('Image utils', () => {
|
|||
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', () => {
|
||||
let mockServeHidpiValue: string | null = null;
|
||||
|
@ -146,6 +162,26 @@ describe('Image utils', () => {
|
|||
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', () => {
|
||||
const { mockElement, mockVideo, playSpy } = createMockElements({
|
||||
extension: 'webm',
|
||||
|
@ -304,24 +340,21 @@ describe('Image utils', () => {
|
|||
const result = showThumb(mockElement);
|
||||
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', () => {
|
||||
const imageFilteredClass = 'image-filtered';
|
||||
const imageShowClass = 'image-show';
|
||||
const spoilerPendingClass = 'spoiler-pending';
|
||||
|
||||
it('should hide the filtered image element and show the image', () => {
|
||||
const mockElement = document.createElement('div');
|
||||
|
||||
const mockFilteredImageElement = document.createElement('div');
|
||||
mockFilteredImageElement.classList.add(imageFilteredClass);
|
||||
mockElement.appendChild(mockFilteredImageElement);
|
||||
|
||||
const mockShowElement = document.createElement('div');
|
||||
mockShowElement.classList.add(imageShowClass);
|
||||
mockShowElement.classList.add(hiddenClass);
|
||||
mockElement.appendChild(mockShowElement);
|
||||
const { mockFilteredImageElement } = createImageFilteredElement(mockElement);
|
||||
const { mockShowElement } = createImageShowElement(mockElement);
|
||||
|
||||
showBlock(mockElement);
|
||||
|
||||
|
@ -329,6 +362,18 @@ describe('Image utils', () => {
|
|||
expect(mockShowElement).not.toHaveClass(hiddenClass);
|
||||
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', () => {
|
||||
|
@ -528,9 +573,7 @@ describe('Image utils', () => {
|
|||
});
|
||||
|
||||
describe('spoilerBlock', () => {
|
||||
const imageFilteredClass = 'image-filtered';
|
||||
const filterExplanationClass = 'filter-explanation';
|
||||
const imageShowClass = 'image-show';
|
||||
const createFilteredImageElement = () => {
|
||||
const mockImageFiltered = document.createElement('div');
|
||||
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
|
||||
|
@ -541,22 +584,31 @@ describe('Image utils', () => {
|
|||
|
||||
return { mockImageFiltered, mockImage };
|
||||
};
|
||||
|
||||
it('should do nothing if image element is missing', () => {
|
||||
const createMockElement = (appendImageShow = true, appendImageFiltered = true) => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should update the elements with the parameters and set classes if image element is found', () => {
|
||||
const mockElement = document.createElement('div');
|
||||
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);
|
||||
const { mockElement, mockImage, mockExplanation, mockImageShow, mockImageFiltered } = createMockElement();
|
||||
|
||||
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
|
||||
|
||||
|
@ -565,5 +617,15 @@ describe('Image utils', () => {
|
|||
expect(mockImageShow).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 { getRandomArrayItem } from '../../../test/randomness';
|
||||
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', () => {
|
||||
const tagStorageKeyPrefix = 'bor_tags_';
|
||||
const mockTagInfo: Record<string, StorageTagInfo> = {
|
||||
const mockTagInfo: Record<string, TagData> = {
|
||||
1: {
|
||||
id: 1,
|
||||
name: 'safe',
|
||||
images: 69,
|
||||
spoiler_image_uri: null,
|
||||
fetchedAt: null,
|
||||
},
|
||||
2: {
|
||||
id: 2,
|
||||
name: 'fox',
|
||||
images: 1,
|
||||
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
|
||||
fetchedAt: null,
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
name: 'paw pads',
|
||||
images: 42,
|
||||
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
|
||||
fetchedAt: null,
|
||||
},
|
||||
4: {
|
||||
id: 4,
|
||||
name: 'whiskers',
|
||||
images: 42,
|
||||
spoiler_image_uri: null,
|
||||
fetchedAt: null,
|
||||
},
|
||||
5: {
|
||||
id: 5,
|
||||
name: 'lilo & stitch',
|
||||
images: 6,
|
||||
spoiler_image_uri: null,
|
||||
fetchedAt: null,
|
||||
},
|
||||
};
|
||||
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
|
||||
|
@ -147,6 +144,12 @@ describe('Tag utilities', () => {
|
|||
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', () => {
|
||||
|
|
|
@ -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 store from './store';
|
||||
|
||||
function showVideoThumb(img) {
|
||||
const size = img.dataset.size;
|
||||
const uris = JSON.parse(img.dataset.uris);
|
||||
function showVideoThumb(img: HTMLDivElement, size: string, uris: Record<string, string>) {
|
||||
const thumbUri = uris[size];
|
||||
|
||||
const vidEl = img.querySelector('video');
|
||||
|
@ -21,18 +19,22 @@ function showVideoThumb(img) {
|
|||
vidEl.classList.remove('hidden');
|
||||
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;
|
||||
}
|
||||
|
||||
export function showThumb(img) {
|
||||
export function showThumb(img: HTMLDivElement) {
|
||||
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 picEl = img.querySelector('picture');
|
||||
if (!picEl) return showVideoThumb(img);
|
||||
if (!picEl) return showVideoThumb(img, size, uris);
|
||||
|
||||
const imgEl = picEl.querySelector('img');
|
||||
if (!imgEl || imgEl.src.indexOf(thumbUri) !== -1) return false;
|
||||
|
@ -45,26 +47,30 @@ export function showThumb(img) {
|
|||
}
|
||||
|
||||
imgEl.src = thumbUri;
|
||||
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
||||
if (!overlay) return false;
|
||||
|
||||
if (uris[size].indexOf('.webm') !== -1) {
|
||||
const overlay = img.querySelector('.js-spoiler-info-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.innerHTML = 'WebM';
|
||||
}
|
||||
else {
|
||||
img.querySelector('.js-spoiler-info-overlay').classList.add('hidden');
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function showBlock(img) {
|
||||
img.querySelector('.image-filtered').classList.add('hidden');
|
||||
const imageShowClasses = img.querySelector('.image-show').classList;
|
||||
imageShowClasses.remove('hidden');
|
||||
imageShowClasses.add('spoiler-pending');
|
||||
export function showBlock(img: HTMLDivElement) {
|
||||
img.querySelector('.image-filtered')?.classList.add('hidden');
|
||||
const imageShowClasses = img.querySelector('.image-show')?.classList;
|
||||
if (imageShowClasses) {
|
||||
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');
|
||||
if (!vidEl) return;
|
||||
|
||||
|
@ -74,15 +80,17 @@ function hideVideoThumb(img, spoilerUri, reason) {
|
|||
|
||||
imgEl.classList.remove('hidden');
|
||||
imgEl.src = spoilerUri;
|
||||
imgOverlay.innerHTML = reason;
|
||||
imgOverlay.classList.remove('hidden');
|
||||
if (imgOverlay) {
|
||||
imgOverlay.innerHTML = reason;
|
||||
imgOverlay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
clearEl(vidEl);
|
||||
vidEl.classList.add('hidden');
|
||||
vidEl.pause();
|
||||
}
|
||||
|
||||
export function hideThumb(img, spoilerUri, reason) {
|
||||
export function hideThumb(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||
const picEl = img.querySelector('picture');
|
||||
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
|
||||
|
||||
|
@ -93,11 +101,13 @@ export function hideThumb(img, spoilerUri, reason) {
|
|||
|
||||
imgEl.srcset = '';
|
||||
imgEl.src = spoilerUri;
|
||||
imgOverlay.innerHTML = reason;
|
||||
imgOverlay.classList.remove('hidden');
|
||||
if (imgOverlay) {
|
||||
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);
|
||||
|
||||
switch (window.booru.spoilerType) {
|
||||
|
@ -114,15 +124,19 @@ export function spoilerThumb(img, spoilerUri, reason) {
|
|||
}
|
||||
}
|
||||
|
||||
export function spoilerBlock(img, spoilerUri, reason) {
|
||||
const imgEl = img.querySelector('.image-filtered img');
|
||||
const imgReason = img.querySelector('.filter-explanation');
|
||||
|
||||
export function spoilerBlock(img: HTMLDivElement, spoilerUri: string, reason: string) {
|
||||
const imgFiltered = img.querySelector('.image-filtered');
|
||||
const imgEl = imgFiltered?.querySelector<HTMLImageElement>('img');
|
||||
if (!imgEl) return;
|
||||
|
||||
imgEl.src = spoilerUri;
|
||||
imgReason.innerHTML = reason;
|
||||
const imgReason = img.querySelector<HTMLElement>('.filter-explanation');
|
||||
const imageShow = img.querySelector('.image-show');
|
||||
|
||||
img.querySelector('.image-show').classList.add('hidden');
|
||||
img.querySelector('.image-filtered').classList.remove('hidden');
|
||||
imgEl.src = spoilerUri;
|
||||
if (imgReason) {
|
||||
imgReason.innerHTML = reason;
|
||||
}
|
||||
|
||||
imageShow?.classList.add('hidden');
|
||||
if (imgFiltered) imgFiltered.classList.remove('hidden');
|
||||
}
|
|
@ -1,31 +1,23 @@
|
|||
// Client-side tag completion.
|
||||
import store from './store';
|
||||
|
||||
/**
|
||||
* @typedef {object} Result
|
||||
* @property {string} name
|
||||
* @property {number} imageCount
|
||||
* @property {number[]} associations
|
||||
*/
|
||||
interface Result {
|
||||
name: string;
|
||||
imageCount: number;
|
||||
associations: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @param {ArrayBuffer} backingStore
|
||||
*/
|
||||
constructor(backingStore) {
|
||||
/** @type {Uint8Array} */
|
||||
constructor(backingStore: ArrayBuffer) {
|
||||
this.data = new Uint8Array(backingStore);
|
||||
/** @type {DataView} */
|
||||
this.view = new DataView(backingStore);
|
||||
/** @type {TextDecoder} */
|
||||
this.decoder = new TextDecoder();
|
||||
/** @type {number} */
|
||||
this.numTags = this.view.getUint32(backingStore.byteLength - 4, true);
|
||||
/** @type {number} */
|
||||
this.referenceStart = this.view.getUint32(backingStore.byteLength - 8, true);
|
||||
/** @type {number} */
|
||||
this.secondaryStart = this.referenceStart + 8 * this.numTags;
|
||||
/** @type {number} */
|
||||
this.formatVersion = this.view.getUint32(backingStore.byteLength - 12, true);
|
||||
|
||||
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.
|
||||
*
|
||||
* @param {number} location
|
||||
* @returns {[string, number[]]}
|
||||
*/
|
||||
getTagFromLocation(location) {
|
||||
getTagFromLocation(location: number): [string, number[]] {
|
||||
const nameLength = this.view.getUint8(location);
|
||||
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.
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {[string, Result]}
|
||||
*/
|
||||
getResultAt(i) {
|
||||
getResultAt(i: number): [string, Result] {
|
||||
const nameLocation = this.view.getUint32(this.referenceStart + i * 8, true);
|
||||
const imageCount = this.view.getInt32(this.referenceStart + i * 8 + 4, true);
|
||||
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.
|
||||
*
|
||||
* @param {number} i
|
||||
* @returns {[string, Result]}
|
||||
*/
|
||||
getSecondaryResultAt(i) {
|
||||
getSecondaryResultAt(i: number): [string, Result] {
|
||||
const referenceIndex = this.view.getUint32(this.secondaryStart + i * 4, true);
|
||||
return this.getResultAt(referenceIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
let min = 0;
|
||||
|
@ -132,7 +110,7 @@ export class LocalAutocompleter {
|
|||
const hiddenTags = window.booru.hiddenTagList;
|
||||
|
||||
while (min < max - 1) {
|
||||
const med = (min + (max - min) / 2) | 0;
|
||||
const med = min + (max - min) / 2 | 0;
|
||||
const sortKey = getResult(med)[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.
|
||||
*
|
||||
* @param {string} prefix
|
||||
* @param {number} k
|
||||
* @returns {Result[]}
|
||||
*/
|
||||
topK(prefix, k) {
|
||||
/** @type {{[key: string]: Result}} */
|
||||
const results = {};
|
||||
topK(prefix: string, k: number): Result[] {
|
||||
const results: Record<string, Result> = {};
|
||||
|
||||
if (prefix === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// Sort results by image count
|
|
@ -1,7 +1,9 @@
|
|||
// Request Utils
|
||||
|
||||
export function fetchJson(verb, endpoint, body) {
|
||||
const data = {
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
|
||||
|
||||
export function fetchJson(verb: HttpMethod, endpoint: string, body?: Record<string, unknown>): Promise<Response> {
|
||||
const data: RequestInit = {
|
||||
method: verb,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
@ -19,7 +21,7 @@ export function fetchJson(verb, endpoint, body) {
|
|||
return fetch(endpoint, data);
|
||||
}
|
||||
|
||||
export function fetchHtml(endpoint) {
|
||||
export function fetchHtml(endpoint: string): Promise<Response> {
|
||||
return fetch(endpoint, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
@ -29,7 +31,7 @@ export function fetchHtml(endpoint) {
|
|||
});
|
||||
}
|
||||
|
||||
export function handleError(response) {
|
||||
export function handleError(response: Response): Response {
|
||||
if (!response.ok) {
|
||||
throw new Error('Received error from server');
|
||||
}
|
|
@ -6,7 +6,7 @@ export const lastUpdatedSuffix = '__lastUpdated';
|
|||
|
||||
export default {
|
||||
|
||||
set(key, value) {
|
||||
set(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
|
@ -16,17 +16,18 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
get(key) {
|
||||
get<Value = unknown>(key: string): Value | null {
|
||||
const value = localStorage.getItem(key);
|
||||
if (value === null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
catch (err) {
|
||||
return value;
|
||||
return value as unknown as Value;
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
remove(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
|
@ -37,8 +38,8 @@ export default {
|
|||
},
|
||||
|
||||
// Watch changes to a specified key - returns value on change
|
||||
watch(key, callback) {
|
||||
const handler = event => {
|
||||
watch(key: string, callback: (value: unknown) => void) {
|
||||
const handler = (event: StorageEvent) => {
|
||||
if (event.key === key) callback(this.get(key));
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
|
@ -46,7 +47,7 @@ export default {
|
|||
},
|
||||
|
||||
// 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 lastUpdatedTime = Date.now() + maxAge;
|
||||
|
||||
|
@ -54,11 +55,11 @@ export default {
|
|||
},
|
||||
|
||||
// Whether the value of a key set with setWithExpireTime() has expired
|
||||
hasExpired(key) {
|
||||
hasExpired(key: string) {
|
||||
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 { 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);
|
||||
}
|
||||
|
||||
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 (a.spoiler_image_uri && b.spoiler_image_uri) {
|
||||
return hidden ? b.images - a.images : a.images - b.images;
|
||||
|
@ -34,16 +42,20 @@ export function getSpoileredTags() {
|
|||
.sort(sortTags.bind(null, false));
|
||||
}
|
||||
|
||||
export function imageHitsTags(img, matchTags) {
|
||||
const imageTags = JSON.parse(img.dataset.imageTags);
|
||||
export function imageHitsTags(img: HTMLImageElement, matchTags: TagData[]): TagData[] {
|
||||
const imageTagsString = img.dataset.imageTags;
|
||||
if (typeof imageTagsString === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
const imageTags = JSON.parse(imageTagsString);
|
||||
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);
|
||||
}
|
||||
|
||||
export function displayTags(tags) {
|
||||
export function displayTags(tags: TagData[]): string {
|
||||
const mainTag = tags[0], otherTags = tags.slice(1);
|
||||
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": {
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@rollup/plugin-multi-entry": "^6.0.0",
|
||||
"@rollup/plugin-typescript": "^11.0.0",
|
||||
"@rollup/plugin-virtual": "^3.0.1",
|
||||
"@types/web": "^0.0.91",
|
||||
"@typescript-eslint/eslint-plugin": "^5.52.0",
|
||||
"@typescript-eslint/parser": "^5.52.0",
|
||||
|
@ -33,10 +35,7 @@
|
|||
"postcss-scss": "^4.0.6",
|
||||
"postcss-url": "^10.1.3",
|
||||
"rollup": "^2.57.0",
|
||||
"rollup-plugin-buble": "^0.19.8",
|
||||
"rollup-plugin-includepaths": "^0.2.4",
|
||||
"rollup-plugin-multi-entry": "^2.1.0",
|
||||
"rollup-plugin-virtual": "^1.0.1",
|
||||
"sass": "^1.58.3",
|
||||
"sass-loader": "^13.2.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
@ -56,6 +55,6 @@
|
|||
"eslint-plugin-jest-dom": "^4.0.3",
|
||||
"jest": "^29.4.3",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"ts-jest": "^29.0.5"
|
||||
"ts-jest": "^29.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,4 +20,5 @@ window.booru = {
|
|||
watchedTagList: [],
|
||||
hiddenFilter: blankFilter,
|
||||
spoileredFilter: blankFilter,
|
||||
tagsVersion: 5
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ES2018",
|
||||
"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}
|
||||
*/
|
||||
spoileredFilter: unknown;
|
||||
tagsVersion: number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
|
|
@ -9,8 +9,7 @@ import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
|
|||
import ESLintPlugin from 'eslint-webpack-plugin';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
|
||||
import rollupPluginMultiEntry from 'rollup-plugin-multi-entry';
|
||||
import rollupPluginBuble from 'rollup-plugin-buble';
|
||||
import rollupPluginMultiEntry from '@rollup/plugin-multi-entry';
|
||||
import rollupPluginTypescript from '@rollup/plugin-typescript';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== 'production';
|
||||
|
@ -18,7 +17,6 @@ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|||
|
||||
const includePaths = rollupPluginIncludepaths();
|
||||
const multiEntry = rollupPluginMultiEntry();
|
||||
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
|
||||
const typescript = rollupPluginTypescript();
|
||||
|
||||
let plugins = [
|
||||
|
@ -105,7 +103,6 @@ export default {
|
|||
loader: 'webpack-rollup-loader',
|
||||
options: {
|
||||
plugins: [
|
||||
buble,
|
||||
includePaths,
|
||||
multiEntry,
|
||||
typescript,
|
||||
|
|
Loading…
Reference in a new issue