1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-02-06 23:32:58 +00:00

15 Commits
0.1.0 ... 0.1.1

Author SHA1 Message Date
d2140c6eee Bumping version to 0.1.1 2024-06-27 02:04:45 +04:00
741bc71f11 Merge pull request #8 from koloml/minor-text-and-styling-changes
Minor content & styling changes and fixes
2024-06-27 02:03:07 +04:00
9732fa2005 Adding more spaces between elements on profile view 2024-06-27 02:01:46 +04:00
c45d4619a8 Renaming main link to "Tagging Profiles" 2024-06-27 02:01:10 +04:00
e42419e3c5 Merge pull request #7 from koloml/tags-editor-refactor
Re-import base input styling removed with Tags Editor styling back to main popup stylesheet
2024-06-27 01:43:15 +04:00
5c48e1cca6 Re-import base input styling to popup stylesheet
Forgot to move import out of tags editor into the main popup stylesheet.
2024-06-27 01:41:17 +04:00
c55b9cc851 Merge pull request #6 from koloml/media-boxes-positions
Fixed Maintenance popups getting out of screen on the start and on the end of the grid rows
2024-06-27 01:37:44 +04:00
723e72b65f Merge pull request #5 from koloml/tags-editor-refactor
Refactoring Tags Editor from Web Components API to Svelte component, improved accessibility for keyboards
2024-06-27 01:37:31 +04:00
8da814c8dd Moving popups of first/last media boxes to not get out of screen 2024-06-27 01:28:53 +04:00
4bd7a67a03 Calculate the first/last media boxes in the row on every resize 2024-06-27 01:12:04 +04:00
b302e8fbb7 Removed tags editor stylesheets from global styles 2024-06-27 00:44:38 +04:00
e6e537ea0c Tags Editor: Removed Web Component with a component made with Svelte
Additionally, this editor works a little bit better when used with
keyboard. It supports tabbing between "remove" buttons inside the tags
and pressing them with Space/Enter.

Web Component was an idea to keep the editor the same between frontend
and Svelte app, but as I figured out later, extensions can't use those.
Unfortunate.
2024-06-27 00:43:29 +04:00
15d318ec90 Merge pull request #1 from koloml/fullscreen-viewer-interactions
Support playing videos in Fullscreen Viewer, closing Viewer on click
2024-06-23 18:26:21 +04:00
a81a7c5d27 Support video content in Fullscreen Viewer 2024-06-23 18:25:05 +04:00
eda7342144 Closing full screen viewer on click 2024-06-23 15:07:16 +04:00
13 changed files with 233 additions and 223 deletions

View File

@@ -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.0",
"version": "0.1.1",
"icons": {
"16": "icon16.png",
"48": "icon48.png",

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",

View File

@@ -1,21 +1,94 @@
<script>
import "$lib/web-components/TagEditorComponent.js";
/**
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
* @type {string[]}
*/
export let tags = [];
let tagsAttribute = tags.join(',');
/** @type {Set<string>} */
let uniqueTags = new Set();
$: uniqueTags = new Set(tags);
/** @type {string} */
let addedTagName = '';
/**
* @param {CustomEvent<string[]>} event
* Create a callback function to pass into both mouse & keyboard events for tag removal.
* @param {string} tagName
* @return {function(Event)} Callback to pass as event listener.
*/
function onTagsChanged(event) {
tags = event.detail;
function createTagRemoveHandler(tagName) {
return event => {
if (event.type === 'click') {
removeTag(tagName);
}
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
// To be more comfortable, automatically focus next available tag's remove button in the list.
if (event.currentTarget instanceof HTMLElement) {
const currenTagElement = event.currentTarget.closest('.tag');
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
const nextRemoveButton = nextTagElement?.querySelector('.remove');
if (nextRemoveButton instanceof HTMLElement) {
nextRemoveButton.focus();
}
}
removeTag(tagName);
}
}
}
$: tagsAttribute = tags.join(',');
/**
* @param {string} tagName
*/
function removeTag(tagName) {
uniqueTags.delete(tagName);
tags = Array.from(uniqueTags);
}
/**
* @param {string} tagName
*/
function addTag(tagName) {
uniqueTags.add(tagName);
tags = Array.from(uniqueTags);
}
/**
* Handle adding new tags to the list or removing them when backspace is pressed.
* @param {KeyboardEvent} event
*/
function handleKeyPresses(event) {
if (event.code === 'Enter' && addedTagName.length) {
addTag(addedTagName)
addedTagName = '';
}
if (event.code === 'Backspace' && !addedTagName.length && tags?.length) {
removeTag(tags[tags.length - 1]);
}
}
</script>
<tags-editor tags="{tagsAttribute}" on:change={onTagsChanged}></tags-editor>
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{tagName}
<span class="remove" on:click={createTagRemoveHandler(tagName)}
on:keydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>
</div>
{/each}
<input type="text" bind:value={addedTagName} on:keydown={handleKeyPresses}/>
</div>
<style lang="scss">
.tags-editor {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
</style>

View File

@@ -1,10 +1,13 @@
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
import {initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
import {calculateMediaBoxesPositions, initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
import {createImageShowFullscreenButton} from "$lib/components/ImageShowFullscreenButton.js";
document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
createMediaBoxTools(
createMaintenancePopup(),
@@ -18,3 +21,5 @@ document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
window.dispatchEvent(new CustomEvent('resize'));
})
});
calculateMediaBoxesPositions(mediaBoxes);

View File

@@ -24,10 +24,34 @@ export class ImageShowFullscreenButton extends BaseComponent {
#onButtonClicked() {
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
let imageElement = imageViewer.querySelector('img') ?? document.createElement('img');
const largeSourceUrl = this.#mediaBoxTools.mediaBox.imageLinks.large;
imageElement.src = this.#mediaBoxTools.mediaBox.imageLinks.large;
imageViewer.appendChild(imageElement);
let imageElement = imageViewer.querySelector('img');
let videoElement = imageViewer.querySelector('video');
if (imageElement) {
imageElement.remove();
}
if (videoElement) {
videoElement.remove();
}
if (largeSourceUrl.endsWith('.webm') || largeSourceUrl.endsWith('.mp4')) {
videoElement ??= document.createElement('video');
videoElement.src = largeSourceUrl;
videoElement.volume = 0;
videoElement.autoplay = true;
videoElement.loop = true;
videoElement.controls = true;
imageViewer.appendChild(videoElement);
} else {
imageElement ??= document.createElement('img');
imageElement.src = largeSourceUrl;
imageViewer.appendChild(imageElement);
}
imageViewer.classList.add('shown');
}
@@ -57,12 +81,38 @@ export class ImageShowFullscreenButton extends BaseComponent {
document.addEventListener('keydown', event => {
// When ESC pressed
if (event.code === 'Escape' || event.code === 'Esc') {
element.classList.remove('shown');
this.#closeFullscreenViewer(element);
}
});
element.addEventListener('click', () => {
this.#closeFullscreenViewer(element);
});
return element;
}
/**
* @param {HTMLElement} [viewerElement]
*/
static #closeFullscreenViewer(viewerElement = null) {
viewerElement ??= this.#resolveFullscreenViewer();
viewerElement.classList.remove('shown');
/** @type {HTMLVideoElement} */
const videoElement = viewerElement.querySelector('video');
if (!videoElement) {
return;
}
// Stopping and muting the video
requestAnimationFrame(() => {
videoElement.volume = 0;
videoElement.pause();
videoElement.remove();
})
}
}
export function createImageShowFullscreenButton() {

View File

@@ -78,6 +78,29 @@ export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
}
}
/**
* @param {NodeListOf<HTMLElement>} mediaBoxesList
*/
export function calculateMediaBoxesPositions(mediaBoxesList) {
window.addEventListener('resize', () => {
/** @type {HTMLElement|null} */
let lastMediaBox = null,
/** @type {number|null} */
lastMediaBoxPosition = null;
for (let mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
})
}
/**
* @typedef {Object} ImageURIs
* @property {string} full

View File

@@ -1,184 +0,0 @@
export default class TagEditorComponent extends HTMLElement {
/**
* Array of elements representing tags.
* @type {HTMLElement[]}
*/
#tagElements = [];
/**
* Generated input for adding new tags to the tag list. Will be rendered on connecting.
* @type {HTMLInputElement|undefined}
*/
#tagInput;
/**
* Cached list of tag names. Changing this value will not automatically change the actual tags.
* @type {Set<string>}
*/
#tagsSet = new Set();
constructor() {
super();
}
connectedCallback() {
if (!this.#tagInput) {
this.#tagInput = document.createElement('input');
this.appendChild(this.#tagInput);
this.#tagInput.addEventListener('keydown', this.#onKeyDownDetectActions.bind(this));
}
if (!this.#tagElements.length) {
this.#renderTags();
}
this.addEventListener('click', this.#onClickDetectTagRemoval.bind(this));
}
/**
* Render the list of tag elements based on the tag attribute. Should be called every time tag attribute is changed.
*/
#renderTags() {
const tags = this.getAttribute(TagEditorComponent.#tagsAttribute) || '';
const updatedTagsSet = new Set(
tags.split(',')
.map(tagName => tagName.trim())
.filter(Boolean)
);
this.#tagsSet = new Set(updatedTagsSet.values());
this.#tagElements = this.#tagElements.filter(tagElement => {
const tagName = tagElement.dataset.tag;
if (!updatedTagsSet.has(tagName)) {
tagElement.remove();
return false;
}
updatedTagsSet.delete(tagName);
return true;
});
for (let tagName of updatedTagsSet) {
const tagElement = document.createElement('div');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
tagElement.dataset.tag = tagName;
const tagRemoveElement = document.createElement("span");
tagRemoveElement.classList.add('remove');
tagRemoveElement.innerText = 'x';
tagElement.appendChild(tagRemoveElement);
this.#tagInput.insertAdjacentElement('beforebegin', tagElement);
this.#tagElements.push(tagElement);
}
}
/**
* Detect add/remove keyboard shortcuts on the input.
* @param {KeyboardEvent} event
*/
#onKeyDownDetectActions(event) {
const isTagSubmit = event.key === 'Enter';
const isTagRemove = event.key === 'Backspace' && !this.#tagInput.value.length;
if (!isTagSubmit && !isTagRemove) {
return;
}
if (isTagSubmit) {
event.preventDefault();
}
const providedTagName = this.#tagInput.value.trim();
if (providedTagName && isTagSubmit) {
if (!this.#tagsSet.has(providedTagName)) {
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet, providedTagName].join(',')
);
}
this.#tagInput.value = '';
return;
}
if (isTagRemove && this.#tagsSet.size) {
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet].slice(0, -1).join(',')
)
}
}
/**
* Detect clicks on the "remove" button inside tags.
* @param {MouseEvent} event
*/
#onClickDetectTagRemoval(event) {
/** @type {HTMLElement} */
const maybeRemoveTagElement = event.target;
if (!maybeRemoveTagElement.classList.contains('remove')) {
return;
}
/** @type {HTMLElement} */
const tagElement = maybeRemoveTagElement.closest('.tag');
if (!tagElement) {
return;
}
const tagName = tagElement.dataset.tag;
if (this.#tagsSet.has(tagName)) {
this.#tagsSet.delete(tagName);
this.setAttribute(
TagEditorComponent.#tagsAttribute,
[...this.#tagsSet].join(",")
);
}
}
/**
* @param {string} name
* @param {string} from
* @param {string} to
*/
attributeChangedCallback(name, from, to) {
if (!this.isConnected) {
return;
}
if (name === TagEditorComponent.#tagsAttribute) {
this.#renderTags();
this.dispatchEvent(
new CustomEvent(
'change',
{
detail: [...this.#tagsSet.values()]
}
)
);
}
}
static get observedAttributes() {
return [this.#tagsAttribute];
}
static #tagsAttribute = 'tags';
}
if (!customElements.get('tags-editor')) {
customElements.define('tags-editor', TagEditorComponent);
} else {
console.warn('Tags Component is attempting to initialize twice!');
}

View File

@@ -4,7 +4,7 @@
</script>
<Menu>
<MenuLink href="/settings/maintenance">Manual Tags Maintenance</MenuLink>
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
<hr>
<MenuLink href="/about">About</MenuLink>
</Menu>

View File

@@ -6,5 +6,5 @@
<Menu>
<MenuLink href="/">Back</MenuLink>
<hr>
<MenuLink href="/settings/maintenance">Maintenance</MenuLink>
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
</Menu>

View File

@@ -43,12 +43,12 @@
<hr>
</Menu>
{#if profile}
<div>
<strong>Profile:</strong><br>
{profile.settings.name}
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div>
<strong>Focused Tags:</strong>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each profile.settings.tags as tagName}
<span class="tag">{tagName}</span>
@@ -67,10 +67,20 @@
{/if}
</MenuLink>
</Menu>
<style>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -95,6 +95,51 @@
display: none;
}
&.media-box--first:not(.media-box--last) {
.media-box-tools:before {
left: -1px;
}
.media-box-tools:after {
right: -75%;
}
.maintenance-popup {
left: -1px;
right: -75%;
}
}
&.media-box--last:not(.media-box--first) {
.media-box-tools:before {
left: -75%;
}
.media-box-tools:after {
right: -1px;
}
.maintenance-popup {
left: -75%;
right: -1px;
}
}
&.media-box--last.media-box--first {
.media-box-tools:before {
left: -1px;
}
.media-box-tools:after {
right: -1px;
}
.maintenance-popup {
left: -1px;
right: -1px;
}
}
&:hover {
.media-box-tools.has-active-profile {
&:before, &:after {
@@ -127,7 +172,7 @@
justify-content: stretch;
align-items: stretch;
img {
img, video {
object-fit: contain;
width: 100%;
height: 100%;

View File

@@ -1,12 +0,0 @@
@use '../colors';
@import "input";
tags-editor {
display: flex;
gap: 5px;
flex-wrap: wrap;
input {
width: 180px;
}
}

View File

@@ -30,6 +30,6 @@ a {
}
}
@import "injectable/tags-editor";
@import "injectable/input";
@import "injectable/tag";
@import "injectable/icons";
@import "injectable/icons";