stashRightClickScenes forking

I have updated the right click scenes as a fork. If you want to use this simply replace the code in stashRightClickScenes.js with the below.

  • Right clicking and add tag now has the same functionality as using the edit function but is better as you can do it wherever you are on the page. It is a cleaner smaller interface. all current tags show at the top. You can search now to add more. Clear the search with the x and create a new tag. Never will you click on add tag again and you have to search for the box by scrolling aroung the page looking for it. It will always appear next to the card.

  • No need to right click and select add tag just click the tag icon to open the above menu directly

  • You can scrub the scene via the sprites as you add as many tags as you want

  • Scene Card Anchored Popups: The Tag, Performer, and Gallery management popups no longer open blindly under the mouse cursor. They now dynamically anchor directly to the right edge of the clicked scene card to keep your preview clear and unobstructed.

  • Smart Boundary Detection & Auto-Flipping: If there isn’t enough horizontal or vertical space on the right side of the screen, the popup automatically flips to the left side or shifts upward to stay fully visible.

  • Modernized Tabulator Integration: Cleaned up styling for Tabulator tables inside popups using a modern light theme, crisp borders, and refined row selections.

  • Pre-selected & Sorted Items: Existing tag, performer, and gallery links are now automatically pre-selected when you open the popup, and items are sorted with selected ones pinned to the top for faster reviewing.

  • Inline Creation & Duplicate Safety: If you type a new item name and click Create, the script handles the inline mutation. If a duplicate item already exists, the server error is caught and displayed cleanly via a notification toast while preserving your search input so you can instantly select the existing entry.

  • Initial Right-Click Menu Integration: Built the base context menu attached to .scene-card elements to handle custom scene actions without having to open the full edit page.

  • Tabulator Popup Framework: Introduced the floating, draggable Tabulator-powered interface cards for managing tags, performers, and galleries right from the main grid view.

  • Bulk Updates & ID Syncing: Implemented full GraphQL synchronization (sceneUpdate) to fetch existing assigned IDs and save bulk changes back to individual scenes cleanly.

  • Inline Creation & Search UI: Added the instant search input, clear button (×), and inline Create button functionality that lets you type a new tag, performer, or gallery on the fly and automatically select it upon creation.

  • Refreshes the page when you save the tag but remembers your position. This was the most tricky element. As wanted it to just update the card but still elegant. Perhaps someone can improve this?

// ==UserScript==
// @name         Stash Scene Manager Context Menu
// @namespace    http://tampermonkey.net/
// @version      1.9.21
// @description  Restricted right-click context menu (only on video preview/thumbnail area) and card-wide left-click badge shortcuts for Stash scenes with Tabulator popups & robust scroll restoration
// @match        http://localhost:*/*
// @match        http://127.0.0.1:*/*
// @grant        none
// @run-at       document-end
// @require      https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator.min.js
// @require      https://cdn.jsdelivr.net/npm/toastify-js
// ==/UserScript==

(async function() {
    'use strict';

    // Robust auto-restore scroll position after reload (handles SPA async rendering)
    const scrollKey = 'stash_scroll_pos_' + window.location.pathname + window.location.search;
    const savedScroll = sessionStorage.getItem(scrollKey);
    if (savedScroll !== null) {
        sessionStorage.removeItem(scrollKey);
        const targetScroll = parseInt(savedScroll, 10);
        let attempts = 0;
        const restoreScroll = () => {
            window.scrollTo(0, targetScroll);
            if (window.scrollY !== targetScroll && attempts < 15) {
                attempts++;
                setTimeout(restoreScroll, 50);
            }
        };
        restoreScroll();
    }

    let isTabActive = true;
    document.addEventListener('visibilitychange', () => {
        isTabActive = !document.hidden;
    });

    if (!document.getElementById('tabulator-external-css')) {
        const tabulatorCSS = document.createElement('link');
        tabulatorCSS.id = 'tabulator-external-css';
        tabulatorCSS.rel = 'stylesheet';
        tabulatorCSS.href = 'https://unpkg.com/tabulator-tables@5.5.2/dist/css/tabulator.min.css';
        document.head.appendChild(tabulatorCSS);
    }

    if (!document.getElementById('toastify-external-css')) {
        const toastifyCSS = document.createElement('link');
        toastifyCSS.id = 'toastify-external-css';
        toastifyCSS.rel = 'stylesheet';
        toastifyCSS.href = 'https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css';
        document.head.appendChild(toastifyCSS);
    }

    const styleId = 'scenes-manager-modern-styles';
    if (!document.getElementById(styleId)) {
        const style = document.createElement('style');
        style.id = styleId;
        style.textContent = `
            #scenes-custom-menu {
                background: #ffffff;
                border: 1px solid #e2e8f0;
                border-radius: 8px;
                padding: 6px;
                box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
                font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                font-size: 13px;
                min-width: 150px;
                animation: menuFadeIn 0.15s ease-out;
            }
            #scenes-custom-menu a {
                display: block;
                padding: 8px 12px;
                color: #1e293b;
                text-decoration: none;
                border-radius: 4px;
                transition: background 0.15s, color 0.15s;
            }
            #scenes-custom-menu a:hover {
                background: #f1f5f9;
                color: #0f172a;
            }
            @keyframes menuFadeIn {
                from { opacity: 0; transform: translateY(-4px); }
                to { opacity: 1; transform: translateY(0); }
            }

            #scenes-popup .tabulator {
                background-color: #ffffff !important;
                border: 1px solid #e2e8f0 !important;
                border-radius: 6px !important;
                font-family: system-ui, -apple-system, sans-serif !important;
                font-size: 12px !important;
            }
            #scenes-popup .tabulator .tabulator-header {
                background-color: #f8fafc !important;
                border-bottom: 1px solid #e2e8f0 !important;
                color: #64748b !important;
            }
            #scenes-popup .tabulator .tabulator-header .tabulator-col {
                background-color: transparent !important;
                border-right: none !important;
            }
            #scenes-popup .tabulator .tabulator-header .tabulator-col-title {
                color: #475569 !important;
                font-weight: 600 !important;
            }
            #scenes-popup .tabulator .tabulator-row {
                background-color: #ffffff !important;
                color: #1e293b !important;
                border-bottom: 1px solid #f1f5f9 !important;
            }
            #scenes-popup .tabulator .tabulator-row:hover {
                background-color: #f1f5f9 !important;
            }
            #scenes-popup .tabulator .tabulator-row.tabulator-selected,
            #scenes-popup .tabulator .tabulator-row.tabulator-selected:hover {
                background-color: #e0e7ff !important;
                color: #1e293b !important;
            }
            #scenes-popup .tabulator .tabulator-placeholder {
                color: #94a3b8 !important;
            }
        `;
        document.head.appendChild(style);
    }

    const fetchGQL = async (query, variables = {}) =>
        fetch('/graphql', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query, variables })
        }).then(res => res.json());

    function showToast(message, type = "success") {
        if (typeof Toastify === 'undefined') return;
        Toastify({
            text: message,
            duration: 2000,
            gravity: "top",
            position: "center",
            backgroundColor: type === "success" ? "#10b981" : "#ef4444",
        }).showToast();
    }

    const toastSuccess = (message, debug) => {
        showToast(message, 'success');
        if (debug) console.log(debug);
    };

    const toastError = (message, debug) => {
        showToast(message, 'error');
        console.error(debug);
    };

    let currentMenu = null;
    let currentPopup = null;

    function closeMenu() {
        if (currentMenu) {
            currentMenu.remove();
            currentMenu = null;
        }
    }

    function createCustomMenu(clickEvent, sceneId, cardElement) {
        const menu = document.createElement('div');
        menu.id = 'scenes-custom-menu';
        menu.style.position = 'absolute';
        menu.style.zIndex = '999999';
        
        const editTagsLink = document.createElement('a');
        editTagsLink.href = '#';
        editTagsLink.textContent = 'Edit Tags...';
        editTagsLink.addEventListener('click', function(e) {
            e.preventDefault();
            closeMenu();
            createTabulatorPopup('Tags', sceneId, fetchTags, fetchExistingTagIds, cardElement);
        });
        menu.appendChild(editTagsLink);

        const editPerformersLink = document.createElement('a');
        editPerformersLink.href = '#';
        editPerformersLink.textContent = 'Edit Performers...';
        editPerformersLink.addEventListener('click', function(e) {
            e.preventDefault();
            closeMenu();
            createTabulatorPopup('Performers', sceneId, fetchPerformers, fetchExistingPerformerIds, cardElement);
        });
        menu.appendChild(editPerformersLink);

        const editGalleriesLink = document.createElement('a');
        editGalleriesLink.href = '#';
        editGalleriesLink.textContent = 'Edit Galleries...';
        editGalleriesLink.addEventListener('click', function(e) {
            e.preventDefault();
            closeMenu();
            createTabulatorPopup('Galleries', sceneId, fetchGalleries, fetchExistingGalleryIds, cardElement);
        });
        menu.appendChild(editGalleriesLink);

        const editSceneLink = document.createElement('a');
        editSceneLink.href = '#';
        editSceneLink.textContent = 'Edit Scene';
        editSceneLink.addEventListener('click', function(e) {
            e.preventDefault();
            closeMenu();
            openEditScenePage(sceneId);
        });
        menu.appendChild(editSceneLink);

        const hr = document.createElement('div');
        hr.style.height = '1px';
        hr.style.background = '#e2e8f0';
        hr.style.margin = '4px 0';
        menu.appendChild(hr);

        const supportLink = document.createElement('a');
        supportLink.href = 'https://www.patreon.com/serechops/membership';
        supportLink.textContent = 'Support';
        supportLink.style.color = '#d97706';
        supportLink.target = '_blank';
        supportLink.addEventListener('click', () => { closeMenu(); });
        menu.appendChild(supportLink);

        document.body.appendChild(menu);
        currentMenu = menu;
        return menu;
    }

    function showCustomMenu(event, sceneId, cardElement) {
        closeMenu();
        if (currentPopup) currentPopup.remove();
        
        const menu = createCustomMenu(event, sceneId, cardElement);
        event.preventDefault();

        const absX = event.clientX + window.scrollX;
        const absY = event.clientY + window.scrollY;

        menu.style.visibility = 'hidden';
        menu.style.top = `${absY}px`;
        menu.style.left = `${absX}px`;

        requestAnimationFrame(() => {
            const rect = menu.getBoundingClientRect();
            let posX = absX;
            let posY = absY;

            if (event.clientX + rect.width > window.innerWidth) {
                posX = (window.innerWidth + window.scrollX) - rect.width - 10;
            }
            if (event.clientY + rect.height > window.innerHeight) {
                posY = (window.innerHeight + window.scrollY) - rect.height - 10;
            }

            menu.style.top = `${posY}px`;
            menu.style.left = `${posX}px`;
            menu.style.visibility = 'visible';
        });

        const handleOutsideClick = (e) => {
            if (!menu.contains(e.target)) {
                closeMenu();
                document.removeEventListener('mousedown', handleOutsideClick);
            }
        };
        document.addEventListener('mousedown', handleOutsideClick);
    }

    function extractSceneId(cardElement) {
        const link = cardElement.querySelector('a[href*="/scenes/"]');
        if (link) {
            const match = link.href.match(/scenes\/(\d+)/);
            if (match) return match[1];
        }
        return null;
    }

    async function createTabulatorPopup(type, sceneId, fetchFunction, fetchExistingIdsFunction, cardElement) {
        const singularType = type === 'Tags' ? 'Tag' : type === 'Performers' ? 'Performer' : 'Gallery';

        const form = document.createElement('form');
        form.id = 'scenes-popup';
        form.style.position = 'absolute';
        form.style.zIndex = '1000000';
        form.style.padding = '14px';
        form.style.background = '#ffffff';
        form.style.border = '1px solid #e2e8f0';
        form.style.borderRadius = '10px';
        form.style.boxShadow = '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)';
        form.style.width = '320px';
        form.style.boxSizing = 'border-box';
        form.style.fontFamily = 'system-ui, -apple-system, sans-serif';
        form.innerHTML = `
            <h2 style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600; cursor: move; user-select: none; color: #0f172a; display: flex; align-items: center; justify-content: space-between;">
                <span>Edit ${type} for Scene</span>
                <span style="font-size: 11px; color: #64748b; font-weight: normal;">Draggable</span>
            </h2>
            <div style="display: flex; gap: 6px; margin-bottom: 10px; align-items: center;">
                <div style="position: relative; flex: 1;">
                    <input type="text" id="${type.toLowerCase()}-search" placeholder="Search ${type}..." style="width: 100%; padding: 7px 28px 7px 10px; box-sizing: border-box; border-radius: 6px; border: 1px solid #cbd5e1; background: #ffffff; color: #1e293b; font-size: 12px; outline: none; transition: border-color 0.15s;">
                    <span id="${type.toLowerCase()}-clear" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); cursor: pointer; color: #94a3b8; font-size: 16px; line-height: 1; display: none; user-select: none;" title="Clear search">&times;</span>
                </div>
                <button type="button" id="${type.toLowerCase()}-create-btn" style="padding: 7px 8px; cursor: pointer; font-size: 12px; font-weight: 500; background: #10b981; color: white; border: none; border-radius: 6px; white-space: nowrap; display: none; transition: background 0.15s;" title="Create new ${singularType}">Create</button>
                <button type="button" id="${type.toLowerCase()}-refresh-btn" style="padding: 7px 8px; cursor: pointer; font-size: 12px; font-weight: 500; background: #64748b; color: white; border: none; border-radius: 6px; white-space: nowrap; transition: background 0.15s;" title="Refresh list and sort selections to top">Refresh</button>
            </div>
            <div id="${type.toLowerCase()}-table" style="margin-bottom: 10px; width: 100%; box-sizing: border-box;"></div>
            <div style="display: flex; gap: 8px;">
                <button type="button" id="add-${type.toLowerCase()}" style="flex: 1; padding: 7px; cursor: pointer; font-size: 12px; font-weight: 500; background: #6366f1; color: white; border: none; border-radius: 6px; transition: background 0.15s;">Save ${type}</button>
                <button type="button" id="cancel" style="flex: 1; padding: 7px; cursor: pointer; font-size: 12px; font-weight: 500; background: #f1f5f9; color: #334155; border: 1px solid #cbd5e1; border-radius: 6px; transition: background 0.15s;">Cancel</button>
            </div>
        `;
        document.body.appendChild(form);
        currentPopup = form;

        const handlePopupOutsideClick = (e) => {
            if (!form.contains(e.target)) {
                form.remove();
                currentPopup = null;
                document.removeEventListener('mousedown', handlePopupOutsideClick);
            }
        };
        setTimeout(() => { document.addEventListener('mousedown', handlePopupOutsideClick); }, 0);

        let isDragging = false;
        let dragOffsetX, dragOffsetY;
        const header = form.querySelector('h2');
        header.onmousedown = function(e) {
            isDragging = true;
            dragOffsetX = e.clientX - form.offsetLeft;
            dragOffsetY = e.clientY - form.offsetTop;
            document.onmousemove = function(e) {
                if (isDragging) {
                    form.style.left = `${e.clientX - dragOffsetX}px`;
                    form.style.top = `${e.clientY - dragOffsetY}px`;
                }
            };
            document.onmouseup = function() {
                isDragging = false;
                document.onmousemove = null;
                document.onmouseup = null;
            };
        };

        const tableColumns = [
            { title: "ID", field: "id", width: 55, hozAlign: "center", headerHozAlign: "center" },
            { title: type === 'Galleries' ? 'Title' : 'Name', field: type === 'Galleries' ? 'title' : 'name', widthGrow: 2, minWidth: 130 },
        ];
        if (type === 'Performers') {
            tableColumns.push({ title: "Disambiguation", field: "disambiguation", widthGrow: 1, minWidth: 90 });
        }

        const table = new Tabulator(`#${type.toLowerCase()}-table`, {
            layout: "fitColumns",
            height: "160px",
            placeholder: "No Data Available",
            selectable: true,
            index: "id",
            columns: tableColumns,
        });

        const existingIds = await fetchExistingIdsFunction(sceneId);
        const selectedIds = new Set(existingIds.map(id => String(id)));
        let isRestoringSelections = false;

        const filterInput = document.getElementById(`${type.toLowerCase()}-search`);
        const clearBtn = document.getElementById(`${type.toLowerCase()}-clear`);
        const createBtn = form.querySelector(`#${type.toLowerCase()}-create-btn`);
        const refreshBtn = form.querySelector(`#${type.toLowerCase()}-refresh-btn`);

        const updateControlsVisibility = () => {
            const hasValue = filterInput.value.trim().length > 0;
            clearBtn.style.display = hasValue ? 'block' : 'none';
            createBtn.style.display = hasValue ? 'block' : 'none';
        };

        table.on("rowSelected", function(row) {
            if (!isRestoringSelections) {
                const id = row.getData().id;
                if (id !== undefined && id !== null) {
                    selectedIds.add(String(id));
                }
                if (filterInput.value.trim().length > 0) {
                    filterInput.value = '';
                    updateControlsVisibility();
                    setTimeout(async () => {
                        await fetchData("");
                        filterInput.focus();
                    }, 0);
                }
            }
        });

        table.on("rowDeselected", function(row) {
            if (!isRestoringSelections) {
                const id = row.getData().id;
                if (id !== undefined && id !== null) {
                    selectedIds.delete(String(id));
                }
            }
        });

        async function fetchData(query) {
            const data = await fetchFunction(query);
            data.sort((a, b) => {
                const aSelected = selectedIds.has(String(a.id));
                const bSelected = selectedIds.has(String(b.id));
                if (aSelected && !bSelected) return -1;
                if (!aSelected && bSelected) return 1;
                const field = type === 'Galleries' ? 'title' : 'name';
                return (a[field] || '').localeCompare(b[field] || '');
            });

            isRestoringSelections = true;
            try {
                await table.setData(data);
                selectedIds.forEach(id => {
                    const row = table.getRow(id);
                    if (row) table.selectRow(row);
                });
            } finally {
                isRestoringSelections = false;
            }
        }

        function debounce(func, wait) {
            let timeout;
            return function(...args) {
                clearTimeout(timeout);
                timeout = setTimeout(() => func.apply(this, args), wait);
            };
        }

        filterInput.addEventListener('input', debounce(e => {
            updateControlsVisibility();
            fetchData(e.target.value);
        }, 300));

        clearBtn.addEventListener('click', () => {
            filterInput.value = '';
            updateControlsVisibility();
            fetchData("");
            filterInput.focus();
        });

        refreshBtn.addEventListener('click', async () => {
            await fetchData(filterInput.value.trim());
        });

        createBtn.addEventListener('click', async () => {
            const val = filterInput.value.trim();
            if (!val) return;
            let newId = null;
            if (type === 'Tags') newId = await createNewTag(val);
            else if (type === 'Performers') newId = await createNewPerformer(val);
            else if (type === 'Galleries') newId = await createNewGallery(val);

            if (newId) {
                selectedIds.add(String(newId));
                filterInput.value = '';
                updateControlsVisibility();
                await fetchData("");
                filterInput.focus();
            }
        });

        await fetchData("");

        document.getElementById(`add-${type.toLowerCase()}`).addEventListener('click', async function() {
            if (!isTabActive) await new Promise(resolve => setTimeout(resolve, 200));

            const allSelectedIds = Array.from(selectedIds);
            let success = false;
            if (type === 'Tags') success = await updateSceneWithTags(sceneId, allSelectedIds);
            else if (type === 'Performers') success = await updateSceneWithPerformers(sceneId, allSelectedIds);
            else if (type === 'Galleries') success = await updateSceneWithGalleries(sceneId, allSelectedIds);
            
            form.remove();
            currentPopup = null;
            document.removeEventListener('mousedown', handlePopupOutsideClick);

            if (success !== false) {
                toastSuccess(`Scene updated! Reloading...`);
                sessionStorage.setItem(scrollKey, window.scrollY);
                setTimeout(() => { window.location.reload(); }, 400);
            }
        });

        document.getElementById('cancel').addEventListener('click', () => {
            form.remove();
            currentPopup = null;
            document.removeEventListener('mousedown', handlePopupOutsideClick);
        });

        const cardRect = cardElement.getBoundingClientRect();
        let popupX = cardRect.right + window.scrollX + 10;
        let popupY = cardRect.top + window.scrollY;

        form.style.visibility = 'hidden';
        form.style.left = `${popupX}px`;
        form.style.top = `${popupY}px`;

        requestAnimationFrame(() => {
            const formRect = form.getBoundingClientRect();
            if (cardRect.right + 10 + formRect.width > window.innerWidth) {
                popupX = cardRect.left + window.scrollX - formRect.width - 10;
            }
            if (cardRect.top + formRect.height > window.innerHeight) {
                popupY = (window.innerHeight + window.scrollY) - formRect.height - 10;
            }
            form.style.left = `${popupX}px`;
            form.style.top = `${popupY}px`;
            form.style.visibility = 'visible';

            filterInput.focus();
        });
    }

    async function fetchTags(query) {
        const gqlQuery = `query ($filter: String!) {
            findTags(tag_filter: { name: { value: $filter, modifier: INCLUDES } }, filter: { per_page: -1 }) {
                tags { id name }
            }
        }`;
        return await fetchGQL(gqlQuery, { filter: query }).then(res => res.data.findTags.tags);
    }

    async function fetchPerformers(query) {
        const gqlQuery = `query ($filter: String!) {
            findPerformers(performer_filter: { name: { value: $filter, modifier: INCLUDES } }, filter: { per_page: -1 }) {
                performers { id name disambiguation }
            }
        }`;
        return await fetchGQL(gqlQuery, { filter: query }).then(res => res.data.findPerformers.performers);
    }

    async function fetchGalleries(query) {
        const gqlQuery = `query ($filter: String!) {
            findGalleries(gallery_filter: { title: { value: $filter, modifier: INCLUDES } }, filter: { per_page: -1 }) {
                galleries { id title }
            }
        }`;
        return await fetchGQL(gqlQuery, { filter: query }).then(res => res.data.findGalleries.galleries);
    }

    async function createNewTag(name) {
        const mutation = `mutation ($name: String!) { tagCreate(input: { name: $name }) { id name } }`;
        const res = await fetchGQL(mutation, { name });
        if (res.errors) { toastError('Failed to create tag', res.errors); return null; }
        toastSuccess('Tag created successfully', res.data.tagCreate);
        return res.data.tagCreate.id;
    }

    async function createNewPerformer(name) {
        const mutation = `mutation ($name: String!) { performerCreate(input: { name: $name }) { id name } }`;
        const res = await fetchGQL(mutation, { name });
        if (res.errors) { toastError('Failed to create performer', res.errors); return null; }
        toastSuccess('Performer created successfully', res.data.performerCreate);
        return res.data.performerCreate.id;
    }

    async function createNewGallery(title) {
        const mutation = `mutation ($title: String!) { galleryCreate(input: { title: $title }) { id title } }`;
        const res = await fetchGQL(mutation, { title });
        if (res.errors) { toastError('Failed to create gallery', res.errors); return null; }
        toastSuccess('Gallery created successfully', res.data.galleryCreate);
        return res.data.galleryCreate.id;
    }

    async function updateSceneWithTags(sceneId, tagIds) {
        const mutationQuery = `mutation ($scene_id: ID!, $tag_ids: [ID!]!) { sceneUpdate(input: { id: $scene_id, tag_ids: $tag_ids }) { id } }`;
        return await fetchGQL(mutationQuery, { scene_id: sceneId, tag_ids: tagIds })
            .then(res => {
                if (res.errors) {
                    toastError('Failed to update the scene with tags', res.errors);
                    return false;
                } else {
                    return true;
                }
            }).catch(err => { toastError('Failed to update the scene with tags', err); return false; });
    }

    async function updateSceneWithPerformers(sceneId, performerIds) {
        const gqlQuery = `mutation ($scene_id: ID!, $performer_ids: [ID!]!) { sceneUpdate(input: { id: $scene_id, performer_ids: $performer_ids }) { id } }`;
        return await fetchGQL(gqlQuery, { scene_id: sceneId, performer_ids: performerIds })
            .then(res => {
                if (res.errors) {
                    toastError('Failed to update the scene with performers', res.errors);
                    return false;
                } else {
                    return true;
                }
            }).catch(err => { toastError('Failed to update the scene with performers', err); return false; });
    }

    async function updateSceneWithGalleries(sceneId, galleryIds) {
        const gqlQuery = `mutation ($scene_id: ID!, $gallery_ids: [ID!]!) { sceneUpdate(input: { id: $scene_id, gallery_ids: $gallery_ids }) { id } }`;
        return await fetchGQL(gqlQuery, { scene_id: sceneId, gallery_ids: galleryIds })
            .then(res => {
                if (res.errors) {
                    toastError('Failed to update the scene with galleries', res.errors);
                    return false;
                } else {
                    return true;
                }
            }).catch(err => { toastError('Failed to update the scene with galleries', err); return false; });
    }

    async function fetchExistingTagIds(sceneId) {
        const query = `query ($id: ID!) { findScene(id: $id) { tags { id } } }`;
        return await fetchGQL(query, { id: sceneId }).then(res => res.data?.findScene?.tags?.map(tag => tag.id) || []);
    }

    async function fetchExistingPerformerIds(sceneId) {
        const query = `query ($id: ID!) { findScene(id: $id) { performers { id } } }`;
        return await fetchGQL(query, { id: sceneId }).then(res => res.data?.findScene?.performers?.map(performer => performer.id) || []);
    }

    async function fetchExistingGalleryIds(sceneId) {
        const query = `query ($id: ID!) { findScene(id: $id) { galleries { id } } }`;
        return await fetchGQL(query, { id: sceneId }).then(res => res.data?.findScene?.galleries?.map(gallery => gallery.id) || []);
    }

    function openEditScenePage(sceneId) {
        const editPageUrl = `/scenes/${sceneId}/edit`;
        const newWindow = window.open(editPageUrl, '_blank');
        if (newWindow) {
            newWindow.onload = () => {
                setTimeout(() => {
                    const editTab = newWindow.document.querySelector('[data-rb-event-key="scene-edit-panel"]');
                    if (editTab) editTab.click();
                }, 1000);
            };
        }
    }

    // Right-click: Restricted ONLY to the video preview/thumbnail frame area
    document.addEventListener('contextmenu', async function(event) {
        const sceneCard = event.target.closest('.scene-card');
        if (!sceneCard) return;

        const previewContainer = event.target.closest('.scene-card-preview, .thumbnail-container, .scene-image, .image-container, .card-image');
        if (!previewContainer) return; // Allows the rest of the card to trigger default Chrome right-click

        const sceneId = extractSceneId(sceneCard);
        if (sceneId) {
            showCustomMenu(event, sceneId, sceneCard);
        }
    });

    // Left-click: Badge shortcuts enabled strictly on badge elements
    document.addEventListener('click', function(event) {
        const sceneCard = event.target.closest('.scene-card');
        if (!sceneCard) return;

        // Target actual tag/performer/gallery pill or badge elements specifically
        const badgeElement = event.target.closest('.tag, .performer-tag, .gallery-tag, .chip, [class*="badge"], [class*="tag"]');
        if (!badgeElement) return;

        const sceneId = extractSceneId(sceneCard);
        if (!sceneId) return;

        const svg = badgeElement.querySelector('svg') || event.target.closest('svg');
        const iconClass = svg ? svg.getAttribute('class') || '' : '';
        const parentHtml = badgeElement.outerHTML;

        if (iconClass.includes('fa-tag') || parentHtml.includes('fa-tag') || badgeElement.classList.contains('tag')) {
            event.preventDefault();
            event.stopPropagation();
            closeMenu();
            createTabulatorPopup('Tags', sceneId, fetchTags, fetchExistingTagIds, sceneCard);
        } 
        else if (iconClass.includes('fa-user') || parentHtml.includes('fa-user') || badgeElement.classList.contains('performer-tag')) {
            event.preventDefault();
            event.stopPropagation();
            closeMenu();
            createTabulatorPopup('Performers', sceneId, fetchPerformers, fetchExistingPerformerIds, sceneCard);
        } 
        else if (iconClass.includes('fa-film') || iconClass.includes('fa-photo-film') || parentHtml.includes('fa-film') || parentHtml.includes('fa-photo-film')) {
            event.preventDefault();
            event.stopPropagation();
            closeMenu();
            createTabulatorPopup('Galleries', sceneId, fetchGalleries, fetchExistingGalleryIds, sceneCard);
        }
    }, true);
})();
1 Like

Bro, I think better if you can just fork it and be a new version. Assuming the License allow for fork.

I cant be bothered with that. I just wanted to make the scene tagging quicker. I have 6000+ scenes to tag lol.

1 Like

I did fork this

Now plays the preview video on a loop* at the top to aid tagging and now updates tags and performers in realtime without reloading page being needed.

  • You need to have created scene previews for this part to work if you don’t have them plugin still works as normal.

1 Like

I am spending far too much time on this lol. I am updating the above so it has a next button so you can quickly tag all scenes/performers on the page. There is was a small bug in the last version if you try to use this on the tags page itself that I squashed (you could not tick the card box to select it). You can now tag the item and click next and it will bring the next scene up autosaving as you go. This will likely be the final rendition. Hope to post to GitHub soon.

We now have dark mode and better UI

Gave it a try, nothing happens for me when I right click scene and clicking any of the option;

@sashi
I think might see the issue and have applied a fix. FastTag now actively checks if Tabulator is missing and dynamically injects <script src="...tabulator.min.js"> and <script src="...toastify.js"> into the webpage automatically.

You can upgrade the plugin in settings plugins to 3.1. Head over ot my GH to examine the code change

1 Like

Thanks to whoever created this page for me.

I have pushed an update today that allows dynamic resizing of the popup window and a few updates to make sure the plugin will work if you never downloaded the original right click suite. This should now work if it is the first plugin you have ever installed.

The resizing is still being tinkered with but I will release updates to GitHub as I go though will slow this down so I can try and capture any bugs before uploading.

This has moved to its own dedicated Plugin page.