Experimental Full Colour Tabs



  • Hi all,

    I saw this feature request, and it got me thinking if I could try and implement it myself. (Someone may have already implemented this, but I wanted to see if I could learn to do it by myself).

    @pioun said in Feature requests for 1.12:

    Full color tabs
    alt text

    So, It seems to work. There are some known issues though:

    • Doesn't exactly match what is in @pioun's suggestion
    • If you change the position of the tabs in settings it requires a browser restart / open all tabs in a new window
    • It is very inefficient right now, consider this alpha-level code, and i'm no vivaldi coding guru
    • The colours are pretty basic and don't exactly match what vivaldi determines them to be (I don't know what algorithm is used for that) - you can see them change to the colour vivaldi registers them as when you switch to that tab

    Here's an image demo-ing what it looks like right now:
    0_1504032131413_Clipboard02.png

    And here's the current state of the code:

    // Observe for tab switches to an internal page
    var webstackChangeObserver = new MutationObserver(
        function(mutations, observer){
            mutations.forEach(function(mutation){
                if(mutation.type === "childList" && mutation.addedNodes.length > 0){
                    mutation.addedNodes.forEach(prepareNewTab);
                }
            });
        }
    );
    
    // Add listeners to all tabs already in window
    function prepareAlreadyExistingTabs(tabstrip){
        for(var i = 0; i < tabstrip.children.length; i++){
            if(tabstrip.children[i].tagName.toUpperCase() === "SPAN"){
                prepareNewTab(tabstrip.children[i]);
            }
        }
    }
    
    // Get the actual tab node and set up observer for changes
    function prepareNewTab(tabSpan){
        var tab = tabSpan.children[0].children[0];
        setupTabDataChangeObserver(tab);
        tabStateChanged(tab);
    }
    
    // Observe tab for changes to either favicon or active state
    var tabDataChangeObserver = new MutationObserver(
        function(mutations, observer){
            mutations.forEach(function(mutation){
                if(mutation.type === "attributes"){
                    // We only care if the tab active state changed or the favicon changed
                    if(mutation.attributeName === "class" && mutation.target.className.indexOf("tab") >= 0){
                        tabStateChanged(mutation.target);
                        return;
                    } else if(mutation.attributeName === "style" && mutation.target.className.indexOf("favicon") >= 0){
                        faviconChanged(mutation.target);
                    }
                }
            });
        }
    )
    var tabDataChangeObserverConfig = {subtree:true, attributes:true}
    function setupTabDataChangeObserver(tab){
        tabDataChangeObserver.observe(tab, tabDataChangeObserverConfig);
    }
    
    // Tab active state changed
    function tabStateChanged(tab){
        var tabHeader = tab.children[0];
        var favicon = "";
        for (var i = 0; i < tabHeader.children.length; i++){
            if(tabHeader.children[i].className.indexOf("favicon") >= 0){
                favicon = tabHeader.children[i].style.backgroundImage;
                break;
            }
        }
        determineTabAccent(tab, favicon);
    }
    
    // favicon changed
    function faviconChanged(faviconSpan){
        var tab = faviconSpan.parentElement.parentElement;
        determineTabAccent(tab, faviconSpan.style.backgroundImage);
    }
    
    // Check favicon and update colour
    function determineTabAccent(tab, favicon){
        if(tab.className.indexOf("active") >= 0){
            // vivaldi already sets if active so dont interfere
            tab.style.backgroundColor = "";
            return;
        }
        if(favicon===""){
            return;
        }
        //favicon = "url('data:image/png;base64,iVBO...ggg==')"
        favicon = favicon.substring(5);
        favicon = favicon.substring(0, favicon.length - 2);
        //favicon = "data:image/png;base64,iVBO...ggg=="
        getAverageRGB(tab, favicon, updateTabAccent);
    }
    
    // Set colour in callback
    function updateTabAccent(tab, rgbAccent){
        tab.style.backgroundColor = rgbToCss(rgbAccent);
    }
    
    //convert rgb triple into css format
    function rgbToCss(rgb){
        return "rgb(" + rgb.r + "," + rgb.g + "," + rgb.b + ")";
    }
    
    // https://stackoverflow.com/questions/2541481/get-average-color-of-image-via-javascript#2541680
    // https://stackoverflow.com/questions/8473205/convert-and-insert-base64-data-to-canvas-in-javascript#8489120
    // Figure out the average colour of the icon
    function getAverageRGB(tab, faviconData, callback) {
        var canvas = document.createElement('canvas'),
            context = canvas.getContext && canvas.getContext('2d'),
            data,
            i = -4,
            length,
            rgb = {r:0,g:0,b:0},
            count = 0;
        
        canvas.width = 18;
        canvas.height = 18;
        var img = new Image();
        img.onload = function() {
            context.drawImage(this, 0, 0, canvas.width, canvas.height);
        
            data = context.getImageData(0, 0, canvas.width, canvas.height);
            length = data.data.length;
    
            while ( (i += 4) < length ) {
                ++count;
                rgb.r += data.data[i];
                rgb.g += data.data[i+1];
                rgb.b += data.data[i+2];
            }
    
            // ~~ used to floor values
            rgb.r = ~~(rgb.r/count);
            rgb.g = ~~(rgb.g/count);
            rgb.b = ~~(rgb.b/count);
    
            callback(tab, rgb);
        }
        img.src = faviconData;
    }
    
    
    // Register the observer once the browser is fully loaded
    setTimeout(function engageObserver(){
        var tabstrip = document.querySelector("#tabs-container .tab-strip");
        if (tabstrip != null) {
            var config = {childList: true};
            webstackChangeObserver.observe(tabstrip, config);
            prepareAlreadyExistingTabs(tabstrip);
        } else {
            setTimeout(engageObserver, 500);
        }
    }, 500)
    

    If anyone wants to try it out, it's installed as per the usual method. See here: https://forum.vivaldi.net/topic/10549/modding-vivaldi
    If anyone has any suggestions for improvements, or wants to share any ideas, they would be welcome.



  • @LonM This is pretty cool - of course as you said not a finished version but I like it. Thanks a lot for the effort. :thumbsup:



  • Update
    I've made some additional changes to the code, attempting to improve its appearance
    0_1504091626029_Clipboard03.png

    Improvement

    • Add gradient as per original idea (helps with white text on lighter colours)
    • Improve efficiency of Observers
    • Attempt to get better colours by upping saturation

    Bugs

    • Some black icons get listed as a deep red
    • Still doesnt get colours as accurate as vivaldi's algorithm
    • Sometimes the favicon changes but the colour gets set to black
    • If you change the position of the tabs in settings it requires a browser restart / open all tabs in a new window
    • There may still be some efficiency problems - I'm pretty sure there's some sort of memory leak in this

    Code

    // Observe for new tabs being made
    var tabstripChangeObserver = new MutationObserver(
        function(mutations, observer){
            mutations.forEach(function(mutation){
                if(mutation.type === "childList" && mutation.addedNodes.length > 0){
                    mutation.addedNodes.forEach(prepareNewTab);
                }
            });
        }
    );
    // Start when the tabstrip is initialized
    setTimeout(function engageObserver(){
        var tabstrip = document.querySelector("#tabs-container .tab-strip");
        if (tabstrip != null) {
            var config = {childList: true};
            tabstripChangeObserver.observe(tabstrip, config);
            prepareAlreadyExistingTabs(tabstrip);
        } else {
            setTimeout(engageObserver, 500);
        }
    }, 500)
    
    // Add listeners to all tabs already in window
    function prepareAlreadyExistingTabs(tabstrip){
        for(var i = 0; i < tabstrip.children.length; i++){
            if(tabstrip.children[i].tagName.toUpperCase() === "SPAN"){
                prepareNewTab(tabstrip.children[i]);
            }
        }
    }
    
    // Get the actual tab node and set up observer for changes
    function prepareNewTab(tabSpan){
        var tab = tabSpan.children[0].children[0];
        var faviconSpan = tab.children[0].children[1];
        setupTabDataChangeObserver(tab);
        faviconChanged(faviconSpan);
        updateTabAccent(tab);
    }
    
    // Observe tab for changes to either favicon or active state
    var tabDataChangeObserver = new MutationObserver(
        function(mutations, observer){
            mutations.forEach(function(mutation){
                if(mutation.attributeName === "class" && mutation.target.className.indexOf("tab") >= 0){
                    updateTabAccent(mutation.target);
                }
                if(mutation.attributeName === "style" && mutation.target.className.indexOf("favicon") >= 0){
                    faviconChanged(mutation.target);
                    updateTabAccent(mutation.target.parentElement.parentElement)
                }
            });
        }
    )
    var classAttribObsrverConfig = {attributes:true, attributeList: ["class"]}
    var styleAttribObsrverConfig = {attributes:true, attributeList: ["style"]}
    function setupTabDataChangeObserver(tab){
        var faviconSpan = tab.children[0].children[1];
        tabDataChangeObserver.observe(tab, classAttribObsrverConfig);
        tabDataChangeObserver.observe(faviconSpan, styleAttribObsrverConfig);
    }
    
    // favicon changed - update stored colours
    function faviconChanged(faviconSpan){
        var tab = faviconSpan.parentElement.parentElement;
        var favicon = faviconSpan.style.backgroundImage;
        if(favicon===""){
            tab.dataset.hslCss = "";
            return;
        }
        //favicon = "url('data:image/png;base64,iVBO...ggg==')"
        favicon = favicon.substring(5);
        favicon = favicon.substring(0, favicon.length - 2);
        //favicon = "data:image/png;base64,iVBO...ggg=="
        var rgb = getAverageRGB(favicon);
        var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
        var correctedHsl = saturateHsl(hsl);
        tab.dataset.hslCss = hslToCss(correctedHsl);
    }
    
    // Set colour based on data values
    // vivaldi already sets if active so dont interfere
    function updateTabAccent(tab){
        tab.style.background = tab.className.indexOf("active") == -1 ? tab.dataset.hslCss : "";
    }
    
    // Convert rgb to hsl
    // https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion#9493060
    function rgbToHsl(r, g, b){
        r /= 255, g /= 255, b /= 255;
        var max = Math.max(r, g, b), min = Math.min(r, g, b);
        var h, s, l = (max + min) / 2;
    
        if(max == min){
            h = s = 0; // achromatic
        }else{
            var d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max){
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            }
            h /= 6;
        }
    
        return [h, s, l];
    }
    
    // Attempt to emulate vivladi colours by upping saturation
    function saturateHsl(hsl){
        hsl[1] = 0.8;
        return hsl;
    }
    
    //convert hsl triple into css format
    function hslToCss(hsl){
        var hslCss = "hsl(" + hsl[0] * 360 + "," + hsl[1] * 100 + "%," + hsl[2] * 100 + "%)";
        return "linear-gradient(" + hslCss + ", hsl(0,0%,0%))";
    }
    
    
    // https://stackoverflow.com/questions/2541481/get-average-color-of-image-via-javascript#2541680
    // https://stackoverflow.com/questions/8473205/convert-and-insert-base64-data-to-canvas-in-javascript#8489120
    // Figure out the average colour of the icon
    function getAverageRGB(faviconData) {
        var canvas = document.createElement('canvas'),
            context = canvas.getContext && canvas.getContext('2d'),
            data,
            i = -4,
            length,
            rgb = {r:0,g:0,b:0},
            count = 0;
        
        canvas.width = 18;
        canvas.height = 18;
        var img = new Image();
        img.src = faviconData;
        context.drawImage(img, 0, 0, canvas.width, canvas.height);
    
        data = context.getImageData(0, 0, canvas.width, canvas.height);
        length = data.data.length;
    
        while ( (i += 4) < length ) {
            ++count;
            rgb.r += data.data[i];
            rgb.g += data.data[i+1];
            rgb.b += data.data[i+2];
        }
    
        // ~~ used to floor values
        rgb.r = ~~(rgb.r/count);
        rgb.g = ~~(rgb.g/count);
        rgb.b = ~~(rgb.b/count);
    
        return rgb;
    }


  • Thank You very much for this! I was in the process of figuring this out for myself. Saved me tons of work.
    I disabled the gradient for my usecase and made the colours a bit darker for readability.

    Also, great to see how such a problem is solved. Taught me some interesting things.

    Edit: One thing i can't quite figure out: Is it possible to also color disabled tabs? I mean the "hibernated" tabs.



  • @LonM Awesome! Its nice to see this idea is getting somewhere. Big thanks LonM for your effort!


Log in to reply
 

Looks like your connection to Vivaldi Forum was lost, please wait while we try to reconnect.