Nexus / UI improvements for CaliberFan operators upgrades page

// ==UserScript==
// @name         UI improvements for CaliberFan operators upgrades page
// @namespace    http://tampermonkey.net/
// @version      2025-06-05--0745
// @description  UI improvements for CaliberFan operators upgrades page
// @author       Nexus <artem.nexus94@gmail.com>
// @include      /^https?:\/\/caliberfan\.com\/wp-admin\/post.php.*$/
// @grant        none
// @updateURL    https://openuserjs.org/meta/Nexus/UI_improvements_for_CaliberFan_operators_upgrades_page.meta.js
// @downloadURL  https://openuserjs.org/install/Nexus/UI_improvements_for_CaliberFan_operators_upgrades_page.user.js
// @copyright    2025, Nexus (https://openuserjs.org/users/Nexus)
// @license      MIT
// ==/UserScript==

var styles = {
    '.operators-upgrades-matrix': [
        'margin-top: 35px;',
        'position: relative;'
    ],

    '.operators-upgrades-matrix__row': [
        'display: flex;',
        'flex-wrap: nowrap;',
        'margin-bottom: -32px;'
    ],

    '.operators-upgrades-matrix__row:last-child': [
        'margin-bottom: 0;'
    ],

    '.operators-upgrades-matrix__row--shifted': [
        'margin-left: 46px;'
    ],

    '.operators-upgrades-matrix__cell': [
        'width: 60px;',
        'height: 60px;',
        'background: #a6a6a6;',
        '-webkit-clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0% 50%);',
        'clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0% 50%);',
        'margin: 0 16px;',
        'cursor: pointer;',
        'transition: border 0.3s, transform 0.3s, opacity 0.3s;',
        'position: relative;',
        'display: flex;',
        'align-items: center;',
        'justify-content: center;',
        'color: #fff;',
        'padding: 10px;',
        'box-sizing: border-box;'
    ],

    '.operators-upgrades-matrix__cell--empty': [
        'opacity: 0.2;'
    ],

    '.operators-upgrades-matrix__cell--empty:hover': [
        'opacity: 0.5;'
    ],

    '.operators-upgrades-matrix__cell--active': [
        'background: #ffcc00;'
    ],

    '.operators-upgrades-matrix__cell::before': [
        'content: \'\';',
        'position: absolute;',
        'top: 2px;',
        'left: 2px;',
        'width: calc(100% - 4px);',
        'height: calc(100% - 4px);',
        'background: #333;',
        '-webkit-clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0% 50%);',
        'clip-path: polygon(25% 5%, 75% 5%, 100% 50%, 75% 95%, 25% 95%, 0% 50%);',
        'z-index: -1;'
    ],

    '.operators-upgrades-matrix__cell img': [
        'max-width: 100%;',
    ],

    '.fast-phrases': [
        'margin-top: 5px;'
    ],

    '.fast-phrases span': [
        'cursor: pointer;',
        'margin-right: 15px;',
        'padding: 1px 5px;',
        'border-radius: 3px;'
    ],

    '.fast-phrases span:hover': [
        'background: #999;',
        'color: #fff;'
    ],
};

function debounce(callback, wait) {
    var timeoutId = null;

    return function (...args) {
        var that = this;

        timeoutId != null && clearTimeout(timeoutId);
        timeoutId = setTimeout(function () {
            callback.apply(that, args);
        }, wait);
  };
};

(function() {
    'use strict';

    var addNewEntryLink = document.querySelector('#wpbody-content .wrap a.page-title-action'),
        container = document.querySelector('#post-body-content');

    if (!addNewEntryLink || !container || addNewEntryLink.href.indexOf('?post_type=prokachka') === -1) {
        return;
    }

    var styleNode = document.createElement('style');
    styleNode.textContent = '';
    styleNode.id = 'UIImprovementsForCaliberFanOperatorsUpgradesPage--runtime-styles';

    Object.keys(styles).forEach(function (key) {
        styleNode.textContent += key + '{' + styles[key].join(';') + '}';
    });

    (document.head || container).appendChild(styleNode);

    var tabControls = [].slice.call(
            document.querySelectorAll('.acf-tab-wrap li a.acf-tab-button'), 0, 6
        ),
        activeTabControl = tabControls.find(function (tab) {
            return tab.closest('li.active') != null;
        }),
        makeClick = function (nodeToClick) {
            if (nodeToClick) {
                nodeToClick.dispatchEvent(
                    new Event('click', {bubbles: true})
                );
            };
        };

    var groupsMatrix = tabControls.map(function (tabControl) {
            makeClick(tabControl);

            return {
                tabControl: tabControl,
                groups: [].slice.call(
                    document.querySelectorAll('.acf-field.acf-field-group:not(.acf-hidden)')
                )
            };
        }),
        getInputGroupNodesByCellIndexes = function (rowIndex, cellIndex) {
            if (!(rowIndex in groupsMatrix) || !(cellIndex in groupsMatrix[rowIndex].groups)) {
                return null;
            };

            return {
                tabControl: groupsMatrix[rowIndex].tabControl,
                group: groupsMatrix[rowIndex].groups[cellIndex],
            };
        };

    makeClick(activeTabControl || tabControls[0]);
    activeTabControl = null;

    function makeOperatorUpgradesMatrix() {
        var container = document.createElement('div');
        container.className = 'operators-upgrades-matrix';

        var onChange = function (groupWrapper, cell) {
            var imageUploader = groupWrapper ? groupWrapper.querySelector('.acf-input .acf-image-uploader') : null,
                previewImg = imageUploader ? imageUploader.querySelector('.show-if-value.image-wrap img') : null,
                isEmpty = !imageUploader || imageUploader.className.indexOf('has-value') === -1,
                cellHasEmptyClass = cell.className.indexOf('operators-upgrades-matrix__cell--empty') !== -1;

            if (isEmpty && !cellHasEmptyClass) {
                cell.className += ' operators-upgrades-matrix__cell--empty';
            } else if (!isEmpty && cellHasEmptyClass) {
                cell.className = cell.className.replace('operators-upgrades-matrix__cell--empty', '');
            }

            cell.innerHTML = (isEmpty || !previewImg) ?
                '<div>' + cell.title + '</div>' :
                '<img src="' + (previewImg.src || '') + '" />';
        };

        var subscriptions = [],
            getListener = function (wrapper, cell) {
                var listener = function (e) {
                    onChange(wrapper, cell);
                };

                subscriptions.push(function () {
                    wrapper.removeEventListener('change', listener);
                });

                return listener;
            };

        for (var rowIndex = 0; rowIndex < 6; rowIndex++) {
            var row = document.createElement('div');
            row.className = 'operators-upgrades-matrix__row';
            var isOdd = rowIndex % 2 === 1;
            if (isOdd) {
                row.className += ' operators-upgrades-matrix__row--shifted';
            }

            for (var cellIndex = 0; cellIndex < (isOdd ? 7 : 8); cellIndex++) {
                var cell = document.createElement('div'),
                    levelNumber = cellIndex * 2 + 1 + +isOdd,
                    optionNumber = Math.floor(rowIndex / 2) + 1,
                    nodes = getInputGroupNodesByCellIndexes(rowIndex, cellIndex),
                    groupWrapper = nodes ? nodes.group : null;

                cell.className = 'operators-upgrades-matrix__cell';
                cell.setAttribute('data-row-index', String(rowIndex));
                cell.setAttribute('data-cell-index', String(cellIndex));

                cell.title = [levelNumber, optionNumber].join('-');
                cell.innerHTML = '<div>' + cell.title + '</div>';

                if (groupWrapper) {
                    groupWrapper.addEventListener('change', getListener(groupWrapper, cell));
                };

                onChange(groupWrapper, cell);

                row.appendChild(cell);
            }

            container.appendChild(row);
        };

        container.addEventListener('click', function (e) {
            var target = e.target;
            if (!target || !target.closest('.operators-upgrades-matrix__cell')) {
                return;
            }

            var cell = target.closest('.operators-upgrades-matrix__cell'),
                rowIndex = +cell.getAttribute('data-row-index'),
                cellIndex = +cell.getAttribute('data-cell-index'),
                nodes = getInputGroupNodesByCellIndexes(rowIndex, cellIndex);

            if (!nodes) {
                return void console.error(
                    new Error('Cannot find ACF\'s group of inputs')
                );
            };


            makeClick(nodes.tabControl);

            window.scrollTo({
                top: document.documentElement.scrollTop + nodes.group.getBoundingClientRect().top - 30
            });

            container.dispatchEvent(new CustomEvent('cell-click', {bubbles: true}));
        });

        container.cancelAllSubscriptions = function () {
            subscriptions.forEach(function (unsubscribe) {
                unsubscribe();
            });
        };

        return container;
    };

    container.appendChild(
        makeOperatorUpgradesMatrix()
    );


    // add Jump To button
    var groupToItsIndexesMap = new WeakMap(),
        groups = groupsMatrix.reduce(function (res, item, rowIndex) {
            return res.concat(
                item.groups.map(function (cell, cellIndex) {
                    groupToItsIndexesMap.set(cell, {
                        rowIndex: rowIndex,
                        cellIndex: cellIndex
                    });

                    return cell;
                })
            );
        }, []);

    var helper = null,
        activeButton = null;

    groups.forEach(function (groupWrapper) {
        var label = groupWrapper.querySelector('.acf-label > label');

        var container = document.createElement('div');
        container.style.marginRight = '15px';
        container.style.display = 'inline-block';

        var button = document.createElement('button');
        button.type = 'button';
        button.className = 'button';
        button.innerHTML = 'Jump to <span style="transform: rotate(90deg); display: inline-block;">➦</span>';

        button.addEventListener('click', function () {
            if (activeButton === button && helper) {
                helper.cancelAllSubscriptions();
                helper.remove();
                helper = activeButton = null;

                return;
            }

            var rect = button.getBoundingClientRect();

            var container = helper || document.createElement('div');
            container.innerHTML = '';
            container.style.padding = '5px';
            container.style.border = 'solid 1px #999';
            container.style.borderRadius = '3px';
            container.style.backgroundColor = '#eee';
            container.style.position = 'absolute';
            container.style.top = (document.documentElement.scrollTop + rect.y + rect.height + 5) + 'px';
            container.style.left = rect.x + 'px';

            var helperMatrix = makeOperatorUpgradesMatrix();
            helperMatrix.style.marginTop = 0;
            helperMatrix.addEventListener('cell-click', function () {
                helperMatrix.cancelAllSubscriptions();
                container.remove();
                helper = activeButton = null;
            });

            if (groupToItsIndexesMap.has(groupWrapper)) {
                [].forEach.call(
                    helperMatrix.querySelectorAll('.operators-upgrades-matrix__cell--active'),
                    function (node) {
                        node.className = node.clasName.replace('operators-upgrades-matrix__cell--active', '');
                    }
                );

                var indexes = groupToItsIndexesMap.get(groupWrapper),
                    cell = helperMatrix.querySelector(
                        '.operators-upgrades-matrix__cell'
                            +'[data-row-index="' + indexes.rowIndex + '"]'
                            +'[data-cell-index="' + indexes.cellIndex + '"]'
                    );

                if (cell) {
                    cell.className += ' operators-upgrades-matrix__cell--active';
                }
            }

            container.appendChild(helperMatrix);
            document.body.appendChild(container);

            activeButton = button;
            container.cancelAllSubscriptions = helperMatrix.cancelAllSubscriptions.bind(helperMatrix);
            helper = container;
        });


        container.appendChild(button);
        label.insertBefore(container, label.firstChild);
    });


    // fix bug with mediafiles "silent pick" that doesn't trigger change event
    if (('MutationObserver' in window)) {
        new MutationObserver(function (entries) {
            [].forEach.call(entries, function (record) {
                if (
                    record.type !== 'attributes' ||
                    record.attributeName !== 'class' ||
                    !record.target.closest('.acf-image-uploader')
                ) {
                    return;
                };

                var input = record.target
                .closest('.acf-image-uploader')
                .querySelector('input[name^="acf["]');

                if (input) {
                    input.dispatchEvent(new Event('change', {bubbles: true}));
                };
            });
        }).observe(document.querySelector('#post-body'), {
            subtree: true,
            attributes: true,
            attributefilter: ['class'],
        });
    };


    // add fast fields filling by using already used values
    var inputsSelector = '.acf-input .acf-input-wrap input[type="text"][name^="acf["]',
        inputsValues = {},
        getVocabularyKey = function (string) {
            return string.toLowerCase().replace(/[\s\.,]+?/guim, '');
        },
        inputsToObserve = groupsMatrix.reduce(function (res, item) {
            item.groups.forEach(function (group) {
                [].slice.call(
                    group.querySelectorAll(inputsSelector)
                ).forEach(function (input, index) {
                    if (!(index in inputsValues)) {
                        inputsValues[index] = {};
                    }

                    var wrapper = input.closest('.acf-input-wrap');
                    var container = document.createElement('div');
                    wrapper.appendChild(container);
                    container.className = 'fast-phrases';

                    var value = input.value.trim(),
                        key = getVocabularyKey(value);

                    if (!(key in inputsValues[index])) {
                        inputsValues[index][key] = {
                            phrase: value,
                            inputs: [],
                            nodes: [],
                        };
                    };

                    inputsValues[index][key].inputs.push(input);

                    (res[index] || (res[index] = [])).push({
                        input: input,
                        container: container
                    });
                });
            });

            return res;
        }, {}),
        addNode = function (vocabularyItem, input) {
            var value = vocabularyItem.phrase;
            if (!value.length) {
                return null;
            }

            var node = document.createElement('span');
            node.textContent = value;

            node.addEventListener('click', function () {
                input.value = value;
                input.dispatchEvent(new Event('input', {bubbles: true}));
                input.dispatchEvent(new Event('change', {bubbles: true}));
                input.focus();
            });

            return node;
        };

    Object.keys(inputsToObserve).forEach(function (fieldIndex) {
        inputsToObserve[fieldIndex].forEach(function (item, index) {
            var container = item.container,
                input = item.input;

            Object.values(inputsValues[fieldIndex] || {}).forEach(function (item) {
                var node = addNode(item, input);
                if (node) {
                    item.nodes.push(node);
                    container.appendChild(node);
                }
            });

            (function (fieldIndex, input, container) {
                var previousValue = input.value.trim();

                input.addEventListener('input', debounce(function () {
                    var value = input.value.trim(),
                        key = getVocabularyKey(previousValue);

                    if (previousValue === value) {
                        return;
                    };

                    if (key in inputsValues[fieldIndex]) {
                        inputsValues[fieldIndex][key].inputs = inputsValues[fieldIndex][key].inputs.filter(function (node) {
                            return node !== input;
                        });

                        if (!inputsValues[fieldIndex][key].inputs.length) {
                            inputsValues[fieldIndex][key].nodes.forEach(function (node) {
                                node.remove();
                            });

                            delete inputsValues[fieldIndex][key];
                        }
                    };

                    previousValue = value;
                    key = getVocabularyKey(value);

                    if (!(key in inputsValues[fieldIndex])) {
                        inputsValues[fieldIndex][key] = {
                            phrase: value,
                            inputs: [],
                            nodes: [],
                        };
                    }

                    inputsValues[fieldIndex][key].inputs.push(input);

                    // it's not newly added phrase, skip list modifying
                    if (inputsValues[fieldIndex][key].inputs.length !== 1) {
                        return;
                    }

                    inputsToObserve[fieldIndex].forEach(function (item, index) {
                        var container = item.container,
                            input = item.input;

                        var node = addNode(inputsValues[fieldIndex][key], input);
                        if (node) {
                            inputsValues[fieldIndex][key].nodes.push(node);
                            container.appendChild(node);
                        }
                    });
                }, 300));

                input.addEventListener('input', debounce(function () {
                    var words = input.value.trim().toLowerCase().split(' ');

                    [].forEach.call(container.querySelectorAll('span'), function (node) {
                        var matched = !words.length || words.find(function (word) {
                            return node.textContent.toLowerCase().indexOf(word) !== -1;
                        }) != null;

                        node.style.display = matched ? '' : 'none';
                    });
                }, 300));
            })(fieldIndex, input, container);

        });
    });
})();