PixAI Utilities Mod

Preloads images; download prompt filename; auto open slideshow; negative prompt persist option; keeps selection highlighted.

当前为 2024-10-15 提交的版本,查看 最新版本

// ==UserScript==
// @name          PixAI Utilities Mod
// @namespace     Violentmonkey Scripts
// @match         https://pixai.art/*
// @version       0.0.0
// @author        brunon
// @description   Preloads images; download prompt filename; auto open slideshow; negative prompt persist option; keeps selection highlighted.
// @grant         GM_addStyle
// @grant         GM_download
// @grant         GM_info
// ==/UserScript==

(async () => {

let imgPreviewSelector = 'main img[src^="https://images-ng.pixai.art/images/thumb/"]';
let imgThumbsSelector = '[data-test-id="virtuoso-item-list"] .contents>button';

let thumbIcons = [];
let openedPreviewCache = new Map();
let lastCacheUpdate = 0;
let slideshowPresent = false;
let dbNameAntiban = `${GM_info.script.name}-${GM_info.uuid}`.replace(/\s+/g, '-');

let generateButtonListener;
let pauseThumbListener = false;
let latestClickedElement;
let currentImgSrcObserver;
let latestEventListener;
let shouldEnforceNegative = false;



async function runCode() {
  await waitForElement(imgThumbsSelector);
  console.log("Running")

}

await runCode();

window.print = function () { };


function preloadImages(imageUrls) {
    imageUrls.forEach(url => {
        const img = new Image();
        img.src = url;
    });
}

async function waitForElements(selector) {
    const startTime = Date.now();
    const waitTime = 10000;

    return new Promise(resolve => {
        const checkInterval = setInterval(() => {
            const elements = document.querySelectorAll(selector);
            if (elements.length >= 4 || Date.now() - startTime > waitTime) {
                clearInterval(checkInterval);
                resolve(elements);
            }
        }, 150);
    });
}

async function updateThumbs(refresh = false) {
    let scroller = document.querySelector('[data-test-id="virtuoso-scroller"]');
    let loader = document.createElement('progress');
    scroller.prepend(loader);

    if (refresh) {
        thumbIcons = [];

        await updatePreviewCache();
    }

    const newThumbs = await waitForElements(imgThumbsSelector);
    newThumbs.forEach(newThumb => {
        let id = extractPreviewId(newThumb);
        displayOpenedState(id, newThumb);

        if (!thumbIcons.includes(newThumb)) thumbIcons.push(newThumb);

    });
    updateListeners();
    loader.remove();
}



async function getImagePreviews() {
    return await waitForElements(imgPreviewSelector);
}



async function preloadFullImages() {
    const imagePreviews = await getImagePreviews();
    preloadImages(Array.from(imagePreviews).map(img => {
        return img.src.replace("thumb", "orig");
    }));
}


async function waitForElement(selector) {
    return new Promise((resolve) => {
        const observer = new MutationObserver(() => {
            const element = document.querySelector(selector);
            if (element) {
                observer.disconnect();
                resolve(element);
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    });
}





async function waitForClass(element, className) {
    return new Promise(resolve => {
        const checkInterval = setInterval(() => {
            if (element.classList.contains(className)) {
                clearInterval(checkInterval);
                resolve();
            }
        }, 100); // Check every 100 milliseconds
    });
}

async function highlightSelected(event) {
    const target = event.currentTarget;

    await waitForClass(target, 'ring-offset-background-light');

    thumbIcons.forEach(icon => {
        icon.classList.remove('selected-thumb');
    });

    target.classList.add('selected-thumb');
}

function eventToElement(event) {
    return event.currentTarget;
}



async function updatePreviewCache() {
    if (Date.now() - lastCacheUpdate < 100) return Promise.resolve();
    lastCacheUpdate = Date.now();

    try {
        const request = openDatabase(); // Get the database request
        const db = await new Promise((resolve, reject) => {
            request.onsuccess = ({ target }) => resolve(target.result); // Resolve with the database object
            request.onerror = () => reject('IndexedDB error');
        });

        const store = db.transaction('previews', 'readonly').objectStore('previews');
        const allValues = [];

        return new Promise((resolve, reject) => {
            const cursorRequest = store.openCursor();
            cursorRequest.onsuccess = ({ target }) => {
                const cursor = target.result;
                if (!cursor) {
                    allValues.forEach(item => openedPreviewCache.set(item.id, item));
                    resolve();
                } else {
                    allValues.push(cursor.value);
                    cursor.continue();
                }
            };
            cursorRequest.onerror = () => reject('Error retrieving cache values');
        });
    } catch (error) {
        console.error("Error accessing the database:", error);
        throw new Error('IndexedDB error');
    }
}

function extractSrcId(src) {
  try {
    return src.split('/').pop();
  } catch (error) {
    console.error('Error occurred with src:', src);
  }
}



function extractPreviewId(element) {
    const img = element.querySelector('div img');
    const src = img?.getAttribute('src');
    if (!src) return null;

    return (extractSrcId(src))
}

function openDatabase() {
    return indexedDB.open(dbNameAntiban, 1);
}

function performDatabaseOperation(id, storeName, operation) {
    const request = openDatabase();
    request.onupgradeneeded = function (event) {
        const db = event.target.result;
        if (!db.objectStoreNames.contains(storeName)) {
            db.createObjectStore(storeName, { keyPath: 'id' });
        }
    };
    request.onsuccess = function (event) {
        const db = event.target.result;
        const transaction = db.transaction(storeName, 'readwrite');
        const store = transaction.objectStore(storeName);
        operation(store, id); // Perform the operation
        transaction.oncomplete = () => null;
        transaction.onerror = () => console.error(`Transaction ${id} error: ${event.target.error}`);
    };
    request.onerror = function (event) {
        console.error('IndexedDB error:', event.target.error);
    };
}





function upsertDatabase(id) {
    // console.log('Upsert ID:', id); // Log the ID being passed
    if (!id) {
        console.error('Invalid ID provided for upsert operation');
        return; // Exit early if ID is invalid
    }
    performDatabaseOperation(id, 'previews', (store, id) => {
        store.put({ id }); // Store the object with the id property
    });
}




async function getValueById(id, storeName) {
    const request = openDatabase();
    return new Promise((resolve, reject) => {
        request.onsuccess = ({ target }) => {
            const store = target.result.transaction(storeName, 'readonly').objectStore(storeName);
            store.get(id).onsuccess = e => resolve(e.target.result || null);
            store.get(id).onerror = () => reject('Error retrieving value');
        };
        request.onerror = () => reject('IndexedDB error');
    });
}

async function alreadyOpened(id, force = false) {
    if (force) {
        const value = await getValueById(id, 'previews');
        if (value) openedPreviewCache.set(id, value);
        return !!value;
    }

    return openedPreviewCache.has(id);
}



function displayOpenedState(id, element, force = false) {
    let existingSpan = element.querySelector('span[data-label="check"]');
    alreadyOpened(id, force).then(isPresent => {
        if (!isPresent && existingSpan) {
            existingSpan.remove(); // Remove the span if ID is not present
            return;
        }
        if (isPresent && !existingSpan) {
            let span = document.createElement('span');
            span.setAttribute('data-label', 'check');
            span.textContent = '✔️';
            element.appendChild(span);
        }
    });
}


function selectThumbFromId(id) {
    let img = document.querySelector(`[data-test-id="virtuoso-item-list"] .contents>button img[src$="${id}"]`);
    if (!img) return;

    thumbIcons.forEach(icon => {
        icon.classList.remove('selected-thumb');
    });
    let elementToSelect = img.parentElement.parentElement;
    elementToSelect.classList.add('selected-thumb');
    console.log("Selecting",elementToSelect,'because of ID',id);
}


function latestClickSrc() {
  if(!latestClickedElement) return {
        src: null,
        img: null
    };

    let latestClickImg = latestClickedElement.querySelector("img");
    if (!latestClickImg) return;

    return {
        src: latestClickImg.src,
        img: latestClickImg
    };
}

async function updateListenersOnNewGeneration() {
    let srcRestore = latestClickSrc();
    if (currentImgSrcObserver) currentImgSrcObserver.disconnect();



    currentImgSrcObserver = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            if (mutation.type === 'attributes' && mutation.attributeName === 'src') {
                selectThumbFromId(extractSrcId(srcRestore.src));
                updateThumbs(true);
                currentImgSrcObserver.disconnect();
            }
        });
    });

    currentImgSrcObserver.observe(srcRestore.img, { attributes: true, attributeFilter: ['src'] });
}





async function addGenerateButtonListener() {
    const button = await waitForElement('[data-tutorial-target="generate-button"]');
    if (!button) return;
    if (generateButtonListener) button.removeEventListener('click', generateButtonListener);
    generateButtonListener = () => {
        updateListenersOnNewGeneration();

    }
    button.addEventListener('click', generateButtonListener);

}
async function checkCompleted() {
  let observer = new MutationObserver(() => {
    if (!document.body.innerText.match(/completed/i)) {
      let firstPreview = document.querySelector(imgPreviewSelector);
      if(firstPreview){
        firstPreview.click();
        observer.disconnect();
      }else{
        let srcRestore = latestClickSrc();
        selectThumbFromId(extractSrcId(srcRestore.src));
      }
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });
}


async function thumbListener(event) {
    let clickedElementTarget = eventToElement(event);

    let latestSrc = latestClickSrc();
    if (latestSrc.src && extractSrcId(clickedElementTarget.src) === extractSrcId(latestSrc.src)) return;




    console.log("Clicked:", clickedElementTarget)

    let id = extractPreviewId(clickedElementTarget);
    latestClickedElement = clickedElementTarget;

    await highlightSelected(event);
    console.log("Was highlight selected!")

    upsertDatabase(id);

    preloadFullImages();
    updateThumbs();


    displayOpenedState(id, clickedElementTarget, true);
    addGenerateButtonListener();


    createEnforceNegativeCheckbox();

    checkCompleted()

}


function manageEnforceNegative(isChecked) {
    const textarea = document.querySelector('textarea[placeholder="Enter negative prompt here"]');
    if (!isChecked) {
        localStorage.removeItem('enforceNegativeNegativePrompt');
        shouldEnforceNegative = false;
        return;
    }
    localStorage.setItem('enforceNegativeNegativePrompt', textarea.value);
    shouldEnforceNegative = true;
}

let toggleCheckbox = (selector, isChecked) => {
    let checkboxLabel = document.querySelector(selector);
    if (!checkboxLabel) return;
    let checkedPath = checkboxLabel.querySelector('.checked-path');

    checkedPath.style.display = !isChecked ? 'none' : 'block';

    let checkbox = checkboxLabel.querySelector('input[type="checkbox"]');
    checkbox.checked = isChecked;

    // console.log("setting",checkbox, "to", isChecked)
};

function syncNegativePrompt() {
    toggleCheckbox('#enforce-negative', shouldEnforceNegative);
    if (!shouldEnforceNegative) return;

    let textarea = document.querySelector('textarea[placeholder="Enter negative prompt here"]');
    if (document.activeElement === textarea) {
        localStorage.removeItem('enforceNegativeNegativePrompt');
        shouldEnforceNegative = false;
        return;
    }

    let storedValue = localStorage.getItem('enforceNegativeNegativePrompt');
    if (storedValue) textarea.value = storedValue;
}


setInterval(syncNegativePrompt, 500);

async function slideShowDowloadButtonManager() {
    slideShowLifetimeMonitor();
    const observer = new MutationObserver(() => {
        const nextButton = document.querySelector('.pswp__button--arrow--next');
        if (!nextButton || !!document.querySelector('#custom-download')) return;

        const button = document.createElement('button');
        button.id = 'custom-download';
        button.innerHTML = `<svg aria-hidden="true" viewBox="0 0 32 32" width="32" height="32"><use class="pswp__icn-shadow" xlink:href="#pswp__icn-download"></use><path d="M20.5 14.3 17.1 18V10h-2.2v7.9l-3.4-3.6L10 16l6 6.1 6-6.1ZM23 23H9v2h14Z" id="pswp__icn-download"></path></svg>`;

        button.onclick = async () => {
            nextButton.style.cursor = 'pointer';
            await saveImage();
        };

        nextButton.insertAdjacentElement('beforebegin', button);
        setTimeout(() => button.classList.add('show'), 10);
        addHideSlideshowListeners(button);
    });

    observer.observe(document.body, { childList: true, subtree: true });
    window.addEventListener('beforeunload', () => {
        observer.disconnect();
    });
}



async function saveImage() {
    const textarea = document.querySelector('textarea.w-full');
    const imgSrc = document.querySelector('#pswp__items .pswp__item[aria-hidden="false"] img.pswp__img')?.src;
    if (!textarea || !imgSrc) return;

    await GM_download({
        url: imgSrc,
        name: `${textarea.value.trim()}.png`,
        saveAs: false
    });
}


function highlightOpenThumbnail() {
    let firstPreviewSrc = document.querySelector(imgPreviewSelector);
    if (!firstPreviewSrc) return console.warn(`Element not found for selector: ${imgPreviewSelector}`);

    let currentId = extractSrcId(firstPreviewSrc.src);
    if (!currentId) return console.warn('Current ID could not be extracted from the image source.');
    console.log("highlightOpenThumbnail(): Selection id", currentId, "from",firstPreviewSrc.src,'of',firstPreviewSrc)
    selectThumbFromId(currentId);
}

function createCheckbox(id, labelText, onChangeFunction) {
    const newLabel = document.createElement('label');
    newLabel.style.userSelect = "none";
    newLabel.id = id;
    newLabel.innerHTML = `
    ${labelText}
    <input type="checkbox" style="display:none">
    <svg class="sc-eDvSVe cSfylm MuiSvgIcon-root MuiSvgIcon-fontSizeMedium" focusable="false" aria-hidden="true" viewBox="0 0 24 24">
      <path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"></path>
      <path class="checked-path" d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
    </svg>`;

    const checkbox = newLabel.querySelector('input[type="checkbox"]');
    const checkedPath = newLabel.querySelector('.checked-path');
    checkbox.checked = shouldEnforceNegative;

    checkbox.addEventListener('change', function () {
        checkedPath.style.display = !this.checked ? 'none' : 'block';
        if (typeof onChangeFunction === 'function') onChangeFunction(this.checked);
    });

    return newLabel;
}

function createEnforceNegativeCheckbox() {
    const negativeLabel = Array.from(document.querySelectorAll('label')).find(label => label.textContent === 'Negative');
    if (!negativeLabel || document.getElementById('enforce-negative')) return;

    negativeLabel.parentElement.insertBefore(createCheckbox('enforce-negative', 'Enforce negative for every task', manageEnforceNegative), negativeLabel);
}



async function slideShowLifetimeMonitor() {
    while (true) {

        while (!document.querySelector('#pswp__items')) await new Promise(resolve => setTimeout(resolve, 100));
        slideshowPresent = true;


        while (!!document.querySelector('#pswp__items')) await new Promise(resolve => setTimeout(resolve, 100));
        slideshowPresent = false;


        await new Promise(resolve => setTimeout(resolve, 100));

        highlightOpenThumbnail();
    }
}

function logBgStyleChanges() {
    const bg = document.querySelector('.pswp__bg');
    if (!bg) return;

    const observer = new MutationObserver(() => {
        console.debug(bg.style.cssText);
    });

    observer.observe(bg, { attributes: true });
}




async function addHideSlideshowListeners(customDownload) {
    if (!customDownload) return;

    const elements = document.querySelectorAll('.pswp__scroll-wrap, .pswp__button--close');
    const bg = document.querySelector('.pswp__bg');

    const hideDownload = async (e) => {
        const initialOpacity = parseFloat(bg.style.opacity) || 1;
        let opacityChanged = false;

        const observer = new MutationObserver(() => {
            if (parseFloat(bg.style.opacity) !== initialOpacity) {
                // console.log(parseFloat(bg.style.opacity))
                customDownload.classList.add('hide');
                opacityChanged = true;
                observer.disconnect();
            }
        });

        observer.observe(bg, { attributes: true });
        document.addEventListener('visibilitychange', () => {
            if (document.visibilityState === 'visible' && !opacityChanged) observer.disconnect();
        });

        await new Promise(resolve => setTimeout(resolve, 2000));
        if (opacityChanged) customDownload.classList.add('hide');
        observer.disconnect();
    };

    elements.forEach(el => el.addEventListener('click', hideDownload));
    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideDownload(e); });
}










function updateListeners() {
    thumbIcons.forEach(icon => {
        if (!icon.thumbClickListenerAdded) {
            icon.addEventListener('click', thumbListener);
            icon.thumbClickListenerAdded = true;
        }
    });
}

async function detectScroll() {
    let scroller = await waitForElements('[data-test-id="virtuoso-scroller"]');
    // if (!scroller.length) return;

    scroller[0].addEventListener('scroll', () => {
        // console.log("scrolling")
        requestAnimationFrame(() => updateThumbs(true));
    });
}


upsertDatabase(1)
detectScroll();
slideShowDowloadButtonManager();
createEnforceNegativeCheckbox();
updateThumbs(true);


window.addEventListener('focus', () => {
    updateThumbs(true);
    highlightOpenThumbnail();
});



GM_addStyle(`
.selected-thumb{
  outline: 2px solid hsla(0, 12%, 85.3%, 0.77);
  transition: outline 100ms;
}
#app .ring-2{
  box-shadow: none;
}
[data-test-id="virtuoso-item-list"] .contents>button img{
  cursor:pointer;
  transition: filter .1s ease;
}
[data-test-id="virtuoso-item-list"] .contents>button img:hover{
filter: brightness(1.05);
}
[data-label="check"]{
  position: absolute;
  left: 0;
  bottom: 0;
}
#custom-download{
  width: 75px;
  height: 100px;
  margin-top: -50px;
  position: absolute;
  top: 50%;
  right: calc(75px + .5rem);
  display: flex; justify-content: center; align-items: center;
  opacity:0;
  will-change: opacity;
  transition: opacity 333ms cubic-bezier(0.4, 0, 0.22, 1);
}
#custom-download.show{
  opacity:1;
}
#custom-download.hide{
  opacity:0;
}
#custom-download > svg{
fill: var(--pswp-icon-color);
/*   color: var(--pswp-icon-color-secondary); */
  width: 60px;
  height: 60px;
}
#custom-download > svg > .pswp__icn-shadow {
	stroke-width: 1px;
}
button[aria-label="Download"][type="button"]{
  display:none
}
`);
  })();