mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-02-06 23:32:58 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eab8d633f | |||
| 68de994811 | |||
| be4aec54fe | |||
| 9ca663ffdb | |||
| bb0f84c9ad | |||
| c8eb54ab98 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
@@ -26,17 +26,6 @@
|
||||
"css": [
|
||||
"src/styles/content/listing.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.js"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
|
||||
|
||||
const siteHeader = document.querySelector('.header');
|
||||
|
||||
if (siteHeader) {
|
||||
initializeSiteHeader(siteHeader);
|
||||
}
|
||||
@@ -151,6 +151,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(true);
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.emit('maintenance-state-change', 'waiting');
|
||||
}
|
||||
@@ -181,20 +186,32 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.emit('maintenance-state-change', 'processing');
|
||||
|
||||
const maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Tags submission failed:', e);
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
this.emit('maintenance-state-change', 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
|
||||
@@ -206,6 +223,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
@@ -276,9 +294,42 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param {boolean} isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
}
|
||||
|
||||
this.#pendingSubmissionCount += isStarted ? 1 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the global window closing event, show the prompt when there are pending submission.
|
||||
*/
|
||||
static #initializeExitPromptHandler() {
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (!this.#pendingSubmissionCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
});
|
||||
}
|
||||
|
||||
static #scrapedAPI = new ScrapedAPI();
|
||||
|
||||
static #delayBeforeSubmissionMs = 500;
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static #pendingSubmissionCount = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
|
||||
@@ -38,7 +38,11 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
this.container.innerText = '✅'
|
||||
this.container.innerText = '✅';
|
||||
break;
|
||||
|
||||
case "failed":
|
||||
this.container.innerText = '⚠️';
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#searchField = null;
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#autoCompleteField = null;
|
||||
/** @type {string|null} */
|
||||
#lastParsedSearchValue = null;
|
||||
/** @type {Token[]} */
|
||||
#cachedParsedQuery = [];
|
||||
|
||||
build() {
|
||||
this.container.classList.add('header__search--completable');
|
||||
|
||||
this.#searchField = this.container.querySelector('input[name=q]');
|
||||
this.#searchField.autocomplete = 'off'; // Browser's auto-complete will get in the way!
|
||||
|
||||
const autoCompleteField = document.createElement('input');
|
||||
autoCompleteField.dataset.ac = 'true';
|
||||
autoCompleteField.dataset.acMinLength = '3';
|
||||
autoCompleteField.dataset.acSource = '/autocomplete/tags?term=';
|
||||
autoCompleteField.classList.add('search-autocomplete-dummy');
|
||||
|
||||
this.#autoCompleteField = autoCompleteField;
|
||||
|
||||
this.container.appendChild(autoCompleteField);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#searchField.addEventListener('input', this.#updateAutoCompletedFragment.bind(this));
|
||||
this.#searchField.addEventListener('keydown', this.#onSearchFieldKeyPressed.bind(this));
|
||||
this.#searchField.addEventListener('selectionchange', this.#updateAutoCompletedFragment.bind(this));
|
||||
}
|
||||
|
||||
#updateAutoCompletedFragment() {
|
||||
const searchableFragment = this.#findCurrentTagFragment();
|
||||
this.#emitAutoComplete(searchableFragment || '');
|
||||
}
|
||||
|
||||
#getInputUserSelection() {
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart,
|
||||
this.#searchField.selectionEnd
|
||||
);
|
||||
}
|
||||
|
||||
#resolveQueryTokens() {
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
this.#lastParsedSearchValue = searchValue;
|
||||
this.#cachedParsedQuery = new QueryLexer(searchValue).parse();
|
||||
|
||||
return this.#cachedParsedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onSearchFieldKeyPressed(event) {
|
||||
// On enter, attempt to replace the current active tag in the query with autocomplete selection
|
||||
if (event.code === 'Enter') {
|
||||
this.#onEnterPressed(event);
|
||||
}
|
||||
|
||||
this.#autoCompleteField.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
keyCode: event.keyCode
|
||||
})
|
||||
);
|
||||
|
||||
// Similarly to the site's autocomplete logic, we need to prevent the arrows up/down from causing any issues
|
||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onEnterPressed(event) {
|
||||
const autocompleteSelection = document.querySelector('.autocomplete__item--selected');
|
||||
|
||||
if (!autocompleteSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeToken = SearchWrapper.#findActiveSearchTermPosition(
|
||||
this.#resolveQueryTokens(),
|
||||
this.#getInputUserSelection(),
|
||||
);
|
||||
|
||||
if (activeToken instanceof TermToken || activeToken instanceof QuotedTermToken) {
|
||||
const selectionStart = activeToken.index;
|
||||
const selectionEnd = activeToken.index + activeToken.value.length;
|
||||
|
||||
let autocompletedValue = autocompleteSelection.dataset.value;
|
||||
|
||||
if (activeToken instanceof QuotedTermToken) {
|
||||
autocompletedValue = `"${QuotedTermToken.encode(autocompletedValue)}"`;
|
||||
}
|
||||
|
||||
this.#searchField.value = this.#searchField.value.slice(0, selectionStart)
|
||||
+ autocompletedValue
|
||||
+ this.#searchField.value.slice(selectionEnd);
|
||||
|
||||
const newSelectionEnd = selectionStart + autocompletedValue.length;
|
||||
|
||||
// Place the caret at the end of the currently active tag.
|
||||
// Actually, this does not work for some reason. After the tag is sent to the field and selection was changed to
|
||||
// the end of the inserted tag, browser just does not scroll the input to the caret position.
|
||||
this.#searchField.focus();
|
||||
this.#searchField.setSelectionRange(newSelectionEnd, newSelectionEnd);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|null}
|
||||
*/
|
||||
#findCurrentTagFragment() {
|
||||
if (!this.#searchField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let searchValue = this.#searchField.value;
|
||||
|
||||
if (!searchValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = SearchWrapper.#findActiveSearchTermPosition(
|
||||
this.#resolveQueryTokens(),
|
||||
this.#getInputUserSelection(),
|
||||
);
|
||||
|
||||
if (token instanceof TermToken) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token instanceof QuotedTermToken) {
|
||||
return token.decodedValue;
|
||||
}
|
||||
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
#emitAutoComplete(userInputFragment) {
|
||||
this.#autoCompleteField.value = userInputFragment;
|
||||
|
||||
// Should be at least one frame away, since input event always removes autocomplete window
|
||||
requestAnimationFrame(() => {
|
||||
this.#autoCompleteField.dispatchEvent(
|
||||
new InputEvent('input', {bubbles: true})
|
||||
);
|
||||
|
||||
const autocompleteContainer = document.querySelector('.autocomplete');
|
||||
|
||||
if (autocompleteContainer) {
|
||||
autocompleteContainer.style.left = `${this.container.offsetLeft}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loosely estimate where current selected search term is located and return it if found.
|
||||
* @param {Token[]} tokens Search value to find the actively selected term from.
|
||||
* @param {number} userSelectionIndex The index of the user selection.
|
||||
* @return {Token|null} Search term object or NULL if nothing found.
|
||||
*/
|
||||
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
|
||||
return tokens.find(
|
||||
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSearWrapper(formElement) {
|
||||
new SearchWrapper(formElement).initialize();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
/** @type {SearchWrapper|null} */
|
||||
#searchWrapper = null;
|
||||
|
||||
build() {
|
||||
const searchForm = this.container.querySelector('.header__search');
|
||||
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#searchWrapper) {
|
||||
this.#searchWrapper.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteHeader(siteHeaderElement) {
|
||||
new SiteHeaderWrapper(siteHeaderElement)
|
||||
.initialize();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.header__search--completable {
|
||||
.search-autocomplete-dummy {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user