dA_ignore

ignores people on dA

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name    	dA_ignore
// @namespace   dA_ignore
// @author  	Dediggefedde
// @description ignores people on dA
// @match   	*://*.deviantart.com/*
// @require   	 http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js
// @version 	2.9
// @grant   	GM.setValue
// @grant   	GM.getValue
// @grant   	GM.xmlHttpRequest
// @grant       GM_addStyle
// @noframes
// ==/UserScript==

/* globals $*/
/* globals DiFi*/

let ignorenames = [];
let wordlist = [];
let settings = {
    hideComments: false,
    hideMessages: true,
    deleteMessages: true,
    hideProfile: true,
    hideDeviations: true,
    submitHideRequest: true,
    autoIgnore: false,
};
let msgbox;
let antiBounce = new Date();
let bounceInterval=500;
let viewtimer=null;
let ctrlDown=false;
let shiftDown=false;
let  hoverDisabled=false;

GM_addStyle(`
#dA_ignore_notify p {
  font-weight: bold;
  text-align: center;
  margin: 0;
  color: var(--g-typography-secondary);
}
#dA_ignore_notify{
  position: fixed;
  width: 400px;
  display: block;
  top: 0%;
  background-color: var(--g-bg-tertiary);
  padding: 10px;
  border-radius: 0 10px 10px 0;
  border: 1px solid var(--g-divider1);
  box-shadow: 1px 1px 2px var(--g-bg-primary);
  transition:left;
  transition-duration:0.5s;
  transform: translateY(100%) translateY(10px);
  color: var(--g-typography-primary);
}
button.dA_ignore_popupbtn{
  text-transform:uppercase;
  background:linear-gradient(242deg,#f00,#ddef31);
  border:none;
  cursor:pointer;
  margin:5px;
  position:absolute;
  right:10px;
  top:10px;
}
button.dA_ignore_popupbtn:active{
  filter:brightness(1.1);
}
body.dA_ignore_hoverDisabled .user-link {
  cursor: no-drop;
}
#dA_ignore_settings{padding:5px;cursor:pointer;margin-left: 10px;font:var(--f-14-reg);}
#dA_ignore_settings.active{background:var(--g-bg-secondary);font:var(--f-14-bld);}
#dA_ignore_settings:hover{background: var(--g-bg-secondary);}

section.dA_ignore_settings h2{
  line-height: 2em;
  font-size: larger;
  font-weight: bold;
}

section.dA_ignore_settings div.rr{
  text-align:right;
}
section.dA_ignore_settings legend{
  margin:15px;
}
section.dA_ignore_settings label{
  margin-left:15px;
}

section.dA_ignore_settings .card {
  background: var(--g-secondary-surface-bg);
  border: 1px solid var(--g-secondary-surface-border);
  border-radius: 6px;
  padding: 16px 24px;
  max-width:480px;
  margin-bottom: 20px;
}

section.dA_ignore_settings .card header h2 {
  margin-bottom: 5px;
}

section.dA_ignore_settings textarea {
  width: 100%;
  margin: 10px 0;
}

section.dA_ignore_settings label {
  display: block;
  margin-bottom: 10px;
  cursor:pointer;
}

section.dA_ignore_settings label small {
  display: block;
  font-size: 0.85em;
  opacity: 0.8;
  margin-left: 20px;
}

section.dA_ignore_settings footer {
  text-align: right;
}

section.dA_ignore_settings .btn {
  cursor: pointer;
  height: auto;
  min-height: 32px;
  padding: 0 24px;
  border:none;
}

section.dA_ignore_settings .btn.primary {
  background: var(--gradient4);
}
section.dA_ignore_settings .btn:hover {
  filter:brightness(1.2);
}
section.dA_ignore_settings .btn:active {
  filter:brightness(0.9);
}
`);

async function loadsettings() {
    let Zignorenames = await GM.getValue('blocklist', null);
    if (Zignorenames != null) ignorenames = Zignorenames.split('\n');
    let Zwordlist = await GM.getValue('wordlist', null);
    if (Zwordlist != null) wordlist = Zwordlist.split('\n');
    let Zsettings = await GM.getValue('settings', null);
    if (Zsettings != null) settings = $.parseJSON(Zsettings);

    if (settings.hideComments == null) settings.hideComments = true;
    if (settings.hideMessages == null) settings.hideMessages = false;
    if (settings.deleteMessages == null) settings.deleteMessages = true;
    if (settings.hideProfile == null) settings.hideProfile = true;
    if (settings.hideDeviations == null) settings.hideDeviations = true;
    if (settings.autoIgnore == null) settings.autoIgnore = false;
}

function inIgnoreList(name) { //n array of string
    let rex;
    for (let n of name) {
        if (n == "" || n == null) continue;
        for (let i of ignorenames) {
            if (i[0] == '#') {
                rex = new RegExp(i.substr(1), "i");
                if (rex.test(n.toLowerCase())) {
                    return true;
                }
            } else {
                if (n.toLowerCase() == i.toLowerCase()) return true;
            }
        }
    }
    return false;
}

function notify(text){
    msgbox.innerHTML="<p>dA_ignore</p>"+text;
    msgbox.style.left="0px";
    if(viewtimer!=null)clearTimeout(viewtimer);
    viewtimer=setTimeout(()=>{msgbox.style.left="-450px";},2000);
}

function ignoreIndex(nam){
    return ignorenames.findIndex(item => nam.toLowerCase() === item.toLowerCase());
}

function unIgnoreName(nam,reload){
    let foundindex = ignoreIndex(nam);
    if(foundindex==-1){
        alert("Unable to remove an ignore of '"+$(this).attr("userid")+"'.\nPlease report the issue to developer of da_ignore at https://www.deviantart.com/dediggefedde/art/dA-Ignore-455554874.");
    }
    ignorenames.splice(foundindex, 1);
    notify(`${nam} no longer ignored!`);
    setTimeout(() => {
        GM.setValue('blocklist', [...new Set(ignorenames)].join("\n"));
        if(reload){
            location.reload();
        }
    }, 0);
}

function ignoreName(nam,reload){
    let foundindex = ignoreIndex(nam);
    if(foundindex>=0){
        notify(`${nam} already ignored!`);
        return;
    }
    ignorenames.push(nam.toLowerCase());
    notify(`${nam} ignored!`);
    setTimeout(() => {
        GM.setValue('blocklist', [...new Set(ignorenames)].join("\n"));
        if(reload){
            location.reload();
        }
    }, 0);
}

function pruf(mutationList, observer){
    init();

    const profPopup=document.querySelector("[data-popper-escaped]:not([dA_ignore])");
    if(profPopup){ //ignored users should not appear in first place
        const nam=profPopup.querySelector("a[data-username]").dataset.username;
        const popupUsernameTex=profPopup.querySelector("a[data-username] span").parentElement.parentElement;
        const ignInd=ignoreIndex(nam);
        popupUsernameTex.insertAdjacentHTML("beforeend",`<button class='dA_ignore_popupbtn'>${ignInd==-1?"":"Un"}ignore</button>`);
        popupUsernameTex.querySelector("button.dA_ignore_popupbtn").addEventListener("click",(ev)=>{
            if(ignInd==-1){
                ignoreName(nam,false);
            }else{
                unIgnoreName(nam,true);
            }
        });
        profPopup.setAttribute("dA_ignore",1);
    }

    //ignore phrases automatically
    if (settings.autoIgnore) {
        wordlist.forEach(function(word, i) {
            // Check if the current page is the notifications page
            if (location.href.includes('deviantart.com/notifications')) {
                // Select all comments on the page
                let comments = $('[data-commentid]>div');
                // Iterate through each comment
                comments.each(function () {
                    let username = $(this).closest('section').find('a.user-link').first().data("username").toLowerCase();
                    if(inIgnoreList(username))return; //skip already ignored comments

                    let commentTextRaw = $(this).text()
                    let commentText = commentTextRaw.toLowerCase();
                    if(word=="")return;
                    if(commentText=="")return;

                    // Check if the comment contains the specified word
                    let rex=new RegExp("\\b"+word+"\\b","i");

                    if (rex.test(commentText) && !$(this).hasClass('flagged')){//commentText.includes(word.toLowerCase()) && !$(this).hasClass('flagged')) {
                        $(this).addClass('flagged');
                        let username = $(this).closest('section').find('a.user-link').first().data("username").toLowerCase();

                        // Check if the username is in the blocklist
                        if (!ignorenames.includes(username)) {
                            ignorenames.push(username);
                            notify(username + " was blocked automatically!");
                            setTimeout(() => {
                                GM.setValue('blocklist', [...new Set(ignorenames)].join("\n")); //save unique array
                            }, 0);
                        }
                    }
                });
            }
        });
    }

    //add unhide buttons
    let spambut = $("div[data-commentid]").filter(function(ind, el) { return el.innerText == "COMMENT HIDDEN"; });
    if (spambut.length > 0 && $("#daIgnore_Unhide").length == 0) {
        let unhidebutton = $("<button id='daIgnore_Unhide'>UnHide</button>")
        .css({
            "position": "absolute",
            "top": "0",
            "right": "0",
            "border": "0",
            "background-color": "#fff",
            "cursor": "pointer",
        });
        spambut.append(unhidebutton);

        let dUrl = $('meta[property="og:url"]').attr('content');
        let deviationID = /(\d+)$/.exec(dUrl);
        if (deviationID != null) { //deviation
            deviationID = deviationID[1];
        } else { //profile
            deviationID = $("header[data-hook=top_nav] a.user-link").data("userid");
        }
        let token = $("input[name=validate_token]").val();
        let commentid = spambut.data("commentid");

        unhidebutton.click(function() {
            let dat = {
                "itemid": deviationID,
                "commentid": commentid,
                "csrf_token": token.toString()
            };
            GM.xmlHttpRequest({
                method: "POST",
                url: "https://www.deviantart.com/_napi/shared_api/comments/unhide",
                headers: {
                    "accept": 'application/json, text/plain, */*',
                    "content-type": 'application/json;charset=UTF-8'
                },
                dataType: 'json',
                data: JSON.stringify(dat),
                onerror: function(response) {
                    console.error("error:", response);
                },
                onload: async function(response) {
                    spambut.find("button").first().click();
                }
            });
        });
    }

    //check visible usernames to be in ignorelist. bnam is results.
    let bnam = $('a.u:not(notignore),a.user-link:not(notignore),div.tt-a:not(notignore),img.avatar:not(notignore)').attr('notignore', '').filter(function() {
        return inIgnoreList([$(this).text(), $(this).attr("username"), $(this).attr("title")]);
    });
    if (bnam.length == 0) return;

    //hide thumbnails in overview
    if (settings.hideDeviations) {
        let thumbs = $("div[data-testid=\"thumb\"]:not(notignore)").attr("notignore", "").filter((id, dl) => {
            let el = $(dl).closest("a");//.find("a[data-hook=\"deviation_link\"]");
            if (el.length > 0) el = el.attr("href");
            else return false;
            el = el.match(/deviantart.com\/(.*?)\//);
            if (!el || el.length == 1) return false;
            return inIgnoreList([el[1]]);
        });
        thumbs.remove();
    }

    //hide the profile page, add unignore buttons

    if (settings.hideProfile &&
        $("#ignore_page_placeholder").length == 0 &&
        $("#nav").length > 0 &&
        inIgnoreList([location.href.match(/deviantart\.com\/(.*)($|\/)/i)[1]])
       ) {
        let replaceSite = '<div align=center style="position: relative;" id="ignore_page_placeholder"><img src="http://fc01.deviantart.net/fs46/f/2009/196/d/4/d49e01f2265f3024db7194a3622a415f.png" alt="user blocked" /><h1>You blocked this user!</h1></div>';
        let contentContainter=document.querySelector("[data-moduleid]")?.parentElement?.parentElement?.parentElement;
        if(contentContainter==null){
            let flb=document.querySelector("body>div:nth-of-type(2)>div:nth-of-type(1)>div:nth-of-type(2)>div:nth-of-type(3)>div:nth-of-type(1)>div:nth-of-type(2)");
            if(flb==null){
                alert("Website structure changed. Please request an update from the developer of da_ignore at https://www.deviantart.com/dediggefedde/art/dA-Ignore-455554874.");
            }
            return;
        }
        contentContainter.innerHTML=replaceSite;

        let ignorebut = $("#da_ignore_but").html("UnIgnore")
        .attr({ "title": "remove from your ignore-list", "id": "da_unignore_but" })
        .off("click");

        ignorebut.click(function(){
            let nam=$(this).attr("username");
            unIgnoreName(nam,true);
        });

        let usernam = document.querySelector("h1 a.user-link[data-username]").dataset.username.toLowerCase();
        ignorebut.attr("username", usernam);
        return;
    }

    //delete notifications (comments etc) by clicking on the "X"/Trashcan
    if (settings.deleteMessages && location.href.includes("deviantart.com/notifications")) {
        bnam.closest("section").find("button[aria-label=Remove]").click();
    } else if (bnam.filter("img.avatar").closest("div.grf-deviants").length > 0) {
        bnam.filter("img.avatar").closest("span.f").remove();
        bnam.filter("img.avatar").closest("div.grf-deviants").remove();
    }
    if (bnam.filter("img.avatar").closest('a').length > 0) {
        bnam.filter("img.avatar").closest("a").remove();
    }

    //submit hiderequest for comments
    if (settings.submitHideRequest) {
        let dUrl = $('meta[property="og:url"]').attr('content');
        let deviationID = /(\d+)$/.exec(dUrl);
        if (deviationID != null) { //deviation
            deviationID = deviationID[1];
        } else { //profile
            deviationID = $("header[data-hook=top_nav] a.user-link").data("userid");
        }

        let token = $("input[name=validate_token]").val();
        let els = bnam.closest("div[data-nc]");


        els.not(".ccomment-hidden").addClass("ccomment-hidden").each((i, el) => {
            let simg = el.querySelector('[data-hook="deviation_link"]');
            let username = el.querySelector('[data-hook="user_link"]').getAttribute('data-username');
            el = el.querySelector("div[data-commentid]");

            if(el!=null){
                let con = el.querySelector(".public-DraftStyleDefault-ltr");
                //   el=el.textContent;

                //if(!el.querySelector('span.public-DraftStyleDefault-ltr').hasClass('flagged')) {
                //}

                if (simg != null) { //deviation
                    deviationID = /(\d+)$/.exec(simg);
                    deviationID = deviationID[1];
                }

                let dat = {
                    "itemid": deviationID,
                    "commentid": $(el).data("commentid"),
                    "csrf_token": token.toString()
                };
                GM.xmlHttpRequest({
                    method: "POST",
                    url: "https://www.deviantart.com/_napi/shared_api/comments/hide",
                    headers: {
                        "accept": 'application/json, text/plain, */*',
                        "content-type": 'application/json;charset=UTF-8'
                    },
                    dataType: 'json',
                    data: JSON.stringify(dat),
                    onerror: function(response) {
                        console.error("error:", response);
                    },
                    onload: async function(response) {
                    }
                });
            }

        });
    }

    //hide messages/comments with the usernames closest section
    if (settings.hideMessages && location.href.includes("deviantart.com/notifications")) {
        bnam.closest("section").remove();
    }

    //hide comments with the username's closest div.ccomment
    if (settings.hideComments) {
        let cContainer = bnam.closest('div[data-hook=comments_thread_item]');
        cContainer.remove();
        bnam.closest('div.ccomment').remove(); //eclipse
        bnam.closest("div[data-indent]").remove();
    }
    if (settings.hideComments && bnam.parents('div.deviation-full-minipage').length > 0) {
        bnam.parents('div.deviation-full-minipage').prev("div.deviation-full-container").remove();
        bnam.parents('div.deviation-full-minipage').remove();
    }

}

function init(){
    document.querySelectorAll('.user-link').forEach(link => {
        const el=link.parentElement.parentElement.parentElement.parentElement.parentElement;
        el.addEventListener('mouseout', function(event) {
            if(hoverDisabled){
                event.preventDefault();
                event.stopPropagation();
                return;
            }
        });
    });

    //is profile, add ignorebutton
    if ($("#nav").length > 0) {
        if ($("#da_ignore_but").length == 0 && $("#da_unignore_but").length==0) {

            let ignoreBut = $('<button id="da_ignore_but"  title="Add to your ignore-list">Ignore</button>');
            let nav = $("#nav").closest("nav");
            nav.append(ignoreBut);

            ignoreBut.css("background-image", "linear-gradient(242deg,#f00,#ddef31)")
                .attr("class", ignoreBut.prev().find("button").last().attr("class"))
                .click(function() {
                notify("Ignore-list updated!");
                ignorenames.push(document.querySelector("h1 a.user-link[data-username]").dataset.username.toLowerCase());
                setTimeout(() => {
                    GM.setValue('blocklist', [...new Set(ignorenames)].join("\n")); //save unique array
                }, 0);
            });
        }
    }

    if(!document.querySelector("#dA_ignore_notify")){
        msgbox=document.createElement("div");
        msgbox.id="dA_ignore_notify";
        msgbox.style.left="-450px";
        document.body.append(msgbox);
    }

    let extadnam=[...document.querySelectorAll(".dA_ignore_externalAddName")];
    if(extadnam.length>0){
        extadnam.forEach(el=>{
            let username=el.innerHTML;
            if (!ignorenames.includes(username)) {
                ignorenames.push(username);
                notify(username + " was blocked automatically!");
                setTimeout(() => {
                    GM.setValue('blocklist', [...new Set(ignorenames)].join("\n")); //save unique array
                }, 0);
            }
            el.remove();
        });
    }

    //settings page, add custom settings
    if (location.href.indexOf('https://www.deviantart.com/account') == 0 && $("#dA_ignore_settings").length==0) {
        let ignoremenu = $("<div id='dA_ignore_settings'>Ignore User</div>");
        $('a[href*="/account/browsing"]').parent().before(ignoremenu);
        ignoremenu.click(function() {
            $(".active").removeClass("active");
            $(this).addClass('active');
            const html = `
<section class="dA_ignore_settings surface">
  <form class="card" id="ignore-users-form">
    <h2>Ignore Users</h2>
    <p class="hint">Separate usernames by line breaks</p>
    <textarea id="da_ignore_textarea" rows="4">${ignorenames.join('\n')}</textarea>
    <div class="rr">
      <button type="submit" id='dA_ignore_submitList' class="smbutton smbutton-green btn primary">Save</button>
    </div>
  </form>
  <form class="card" id="behavior-form">
    <h2>Behavior</h2>
    <fieldset>
      <legend>Visibility</legend>
      <label>
        <input type="checkbox" id="da_ignore_hideComments" ${settings.hideComments ? 'checked' : ''}>
        Hide Comments
        <small>This hides comments from ignored users.</small>
      </label>

      <label>
        <input type="checkbox" id="da_ignore_hideProfile" ${settings.hideProfile ? 'checked' : ''}>
        Hide Profile
        <small>Shows a notice instead of the profile.</small>
      </label>

      <label>
        <input type="checkbox" id="da_ignore_hideDeviations" ${settings.hideDeviations ? 'checked' : ''}>
        Hide Deviations
        <small>Removes submissions from feeds.</small>
      </label>
    </fieldset>

    <fieldset>
      <legend>Messages</legend>

      <label>
        <input type="radio" name="messageMode" id="da_ignore_hideMessages" ${settings.hideMessages ? 'checked' : ''}>
        Hide Messages
        <small>Messages remain but are hidden.</small>
      </label>

      <label>
        <input type="radio" name="messageMode" id="da_ignore_deleteMessages" ${settings.deleteMessages ? 'checked' : ''}>
        Delete Messages
        <small>Messages are permanently removed.</small>
      </label>
    </fieldset>

    <fieldset>
      <legend>Automation</legend>

      <label>
        <input type="checkbox" id="da_ignore_submitHideRequest" ${settings.submitHideRequest ? 'checked' : ''}>
        Hide comments publicly
      </label>

      <label>
        <input type="checkbox" id="da_ignore_autoignore" ${settings.autoIgnore ? 'checked' : ''}>
        Auto-ignore users
      </label>
    </fieldset>

    <div class="rr">
      <button type="submit" id='dA_ignore_submitSetting' class="smbutton smbutton-green btn primary">Save</button>
    </div>
  </form>
  <form class="card" id="auto-ignore-form">
    <h2>Auto Ignore</h2>
    <p class="hint">Separate phrases by line breaks (RegEx supported)</p>

    <textarea id="da_ignorewords_textarea" rows="4">${wordlist.join('\n')}</textarea>

    <div class="rr">
      <button type="submit" id='dA_ignore_submitAuto' class="smbutton smbutton-green btn primary">Save</button>
    </div>
  </form>
</section>`;

            $("section.surface").parent().first().html(html);
            let containerClass= ignoremenu.parent().first().attr("class").split(/\s+/).join(".")
            let cssStr=`.${containerClass} div{font:var(--f-14-reg);}
                        .${containerClass} a{background:unset;}`
            GM_addStyle(cssStr);

            $('#dA_ignore_submitList').click((e) => {
                e.preventDefault();
                ignorenames = $('#da_ignore_textarea').val().toLowerCase().split('\n');
                setTimeout(() => {
                    GM.setValue('blocklist', [...new Set(ignorenames)].join("\n"));
                }, 0);
                notify("List saved!");
            });
            $('#dA_ignore_submitSetting').click((e) => {
                e.preventDefault();
                settings.hideComments = $('#da_ignore_hideComments').prop('checked');
                settings.hideMessages = $('#da_ignore_hideMessages').prop('checked');
                settings.deleteMessages = $('#da_ignore_deleteMessages').prop('checked');
                settings.hideProfile = $('#da_ignore_hideprofile').prop('checked');
                settings.hideDeviations = $('#da_ignore_hideDeviations').prop('checked');
                settings.submitHideRequest = $('#da_ignore_submitHideRquest').prop('checked');
                settings.autoIgnore = $('#da_ignore_autoignore').prop('checked');
                setTimeout(() => {
                    GM.setValue('settings', JSON.stringify(settings));
                }, 0);
                notify("Settings saved!");
            });
            $('#dA_ignore_submitAuto').click((e) => {
                e.preventDefault();
                wordlist = $('#da_ignorewords_textarea').val().toLowerCase().split('\n');
                setTimeout(() => {
                    GM.setValue('wordlist', [...new Set(wordlist)].join("\n"));
                }, 0);
                notify("Automation mode saved!");
            });
        });
    }
}

let debounceTimeout = null; // Debounce-Timer
//delayed debounce to avoid calling it multiple times at once
function debouncer(){
    if (debounceTimeout) { //within bounce interval
        clearTimeout(debounceTimeout);
    }
    debounceTimeout = setTimeout(() => {
        pruf();
        debounceTimeout = null;
    }, bounceInterval);
}

if (window.top === window.self) {
    let prom = loadsettings();
    prom.then(()=>{
        const observer = new MutationObserver(debouncer);
        observer.observe(document.body,{ childList: true, subtree: true });
        debouncer();

        document.addEventListener('click', function(event) {
            const target = event.target.closest('a');
            if (target && event.ctrlKey && event.shiftKey && event.button === 0 && target.classList.contains('user-link')) {
                if(ignoreIndex(target.dataset.username)==-1){
                    ignoreName(target.dataset.username,false);
                }else{
                    unIgnoreName(target.dataset.username,true);
                }
                event.preventDefault();
            }
        });

        function updateHoverState() {
            hoverDisabled = ctrlDown && shiftDown;
            document.body.classList.toggle("dA_ignore_hoverDisabled",hoverDisabled);
        }
        document.addEventListener('keydown', (event) => {
            if (event.repeat) return;
            if (event.key === 'Control') ctrlDown = true;
            if (event.key === 'Shift') shiftDown = true;
            updateHoverState();
        });

        document.addEventListener('keyup', (event) => {
            if (event.key === 'Control') ctrlDown = false;
            if (event.key === 'Shift') shiftDown = false;
            updateHoverState();
        });
    });
}