DEV Community

loading...
Cover image for A Complete Guide to Creating a Chrome Extension

A Complete Guide to Creating a Chrome Extension

paymon123 profile image Paymon Wang Lotfi Updated on ・10 min read

In this guide we are going to create Blinknotes, a lightweight app that uses NLP to summarize anything from news articles, research papers, blog posts, or long comments. We’ll discuss the defining aspects of a Chrome extension, implement solutions to edge cases, and adhere to the conventions imposed by the Chromium developers.

Manifest

The manifest defines app properties, permissions, and scripts.

manifest.json

{
  "name": "Blinknotes",
  "version": "0.0.0.1",
  "description": "A lightweight tool to summarize news articles, research papers, blog posts, or long comments.",
  "permissions": ["storage", "contextMenus", "*://*/*"],
  "background": {
    "scripts": ["scripts/background.js"],
    "persistent": false
  },
  "browser_action": {
  "default_popup": "popup.html",
  "default_icon": {
    "16": "images/brain16.png",
    "32": "images/brain32.png",
    "48": "images/brain48.png",
    "128": "images/brain128.png"
    }
  },
  "icons": {
    "16": "images/brain16.png",
    "32": "images/brain32.png",
    "48": "images/brain48.png",
    "128": "images/brain128.png"
  },
  "manifest_version": 2
}
Enter fullscreen mode Exit fullscreen mode

Icons

Paint.net is great for basic image editing, it has most of the functionality of photoshop but is easier to learn and free. I used it to create the banner and 16x16, 32x32, 48x48, and 128x128 pixel files.

images/banner.png
Alt Text
images/brain16.png
Alt Text
images/brain32.png
Alt Text
images/brain48.png
Alt Text
images/brain128.png
Alt Text

Options Menu

Alt Text
Relatively straightforward, define the options HTML file in the manifest and add corresponding JS/CSS.

popup.html

<!DOCTYPE html>
<html >   
  <head style="background-color:white;">
  <link rel="stylesheet" href="/css/style.css">     
  </head>
  <body >
    <img src="images/banner.png" alt="" width="170px" height="60px" style = "background-color:white; padding: 0px 0px 0px 0px;margin-left:10px;">
      <div  style="border-bottom:none; padding:0px 0px 0px 0px;">    
        <div style = "width: 200px; height:40px;">
        <label for="myRange" class="short-text ">Relative Length - 2</label>
        <input  style = "width: 150px; " type="range" min="1" max="10" value="2" class="slider glow-on-hover" id="myRange"> 
        </div>
      </div>
  </div>
  <script src="/scripts/popup.js"></script>    
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

popup.js

let slider = document.getElementById("myRange");
chrome.storage.local.get('length', function(data) { 
    if (data.length === undefined) {
        chrome.storage.local.set({
            length: 2
        }, function() {});
        return;
    }
    let length_value = data.length;
    let text = document.querySelector(".short-text");
    text.innerHTML = "Relative Length - " + length_value;
    slider.setAttribute('value', length_value);
});
slider.oninput = function() {
    let text = document.querySelector(".short-text");
    text.innerHTML = "Relative Length - " + this.value;
    chrome.storage.local.set({
        length: this.value
    }, function() {});
}
Enter fullscreen mode Exit fullscreen mode

style.css

body {
  width:200px;
  height:140px;
  background-color:#fff;
  border:none;
  margin:0px;
  font-family: Calibri;
}
div {
  text-align:left;
  background-color:#f4f4f4;
  padding-left:10px;
  padding-top: 5px;
  padding-bottom:5px;
}
p {
  font-size:14px;
}
label {
  font-size:14px;
  line-height:15px;
}
input {
  line-height:15px;
}
.short-text {
  line-height:20px;
}
.slider {
  -webkit-appearance: none;
  width: 100%;
  height: 3px;
  border-radius: 5px;  
  background: #000000;
  outline: none;
  opacity: 0.7;
  -webkit-transition: .2s;
  transition: opacity .2s;
}
.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 15px;
  height: 15px;
  border-radius: 50%; 
  background: #FFFFFF;
  cursor: pointer;
  border:2px solid black;
}
.slider::-moz-range-thumb {
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: #FFFFFF;
  border:2px solid black;
  cursor: pointer;
}
.glow-on-hover {
  border: none;
  outline: none;
  color: #111;    
  cursor: pointer;
  position: relative;
  z-index: 0;
  border-radius: 10px;
}
.glow-on-hover:before {
  content: '';
  background: linear-gradient(45deg, #E8EBF2, #E4DCEF, #f4f4f4,   #E3E8F2, #D3E9ED, #B0CBE8, #E4E1F2, #C7D1EA,  #f4f4f4);
  position: absolute;
  top: -2px;
  left:-2px;
  background-size: 400%;
  z-index: -1;
  filter: blur(5px);
  width: calc(100% + 4px);
  height: calc(100% + 4px);
  animation: glowing 20s linear infinite;
  opacity: 0;
  transition: opacity .3s ease-in-out;
  border-radius: 10px;
  color:#111;    
}
.glow-on-hover:active {
  color:#fff;
}
.glow-on-hover:active:after {
  background: transparent;
}
.glow-on-hover:hover:before {
  opacity: 1;
  color:#111;
}
.glow-on-hover:after {
  z-index: -1;
  content: '';
  position: absolute;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  border-radius: 10px;
  color:#111;
}
@keyframes glowing {
  0% { background-position: 0 0; }
  50% { background-position: 400% 0; }
  100% { background-position: 0 0; }
}
.glow-on-hover-check {    
  border: none;
  outline: none;    
  position: relative;
  z-index: 0;
  border-radius: 10px;
}
.glow-on-hover-check:before {
  content: '';
  background: linear-gradient(45deg, #E8EBF2, #E4DCEF, #f4f4f4,   #E3E8F2, #D3E9ED, #B0CBE8, #E4E1F2, #C7D1EA,  #f4f4f4);
  position: absolute;
  top: -2px;
  left:-2px;
  background-size: 400%;
  z-index: -1;
  filter: blur(5px);
  width: calc(100% + 4px);
  height: calc(100% + 4px);
  animation: glowing 20s linear infinite;
  opacity: 0;
  transition: opacity .3s ease-in-out;
  border-radius: 10px;
}
.glow-on-hover-check:active:after {
  background: transparent;
}
.glow-on-hover-check:hover:before {
  opacity: 1;
}
.glow-on-hover-check:after {
  z-index: -1;
  content: '';
  position: absolute;
  width: 100%;
  height: 100%;   
  left: 0;
  top: 0;
  border-radius: 10px;
}
Enter fullscreen mode Exit fullscreen mode

Background Script

Alt Text
The general pattern is one background script per browser instance. The background script can handle almost any browser event including installing the app, opening a new tab, closing a tab, updating a URL, right-clicking the context menu, but it does not have access to the current tab’s DOM.

This background script connects to a text summarization API with 2 endpoints, depending on highlighted text or entire article. It will use the chrome storage to determine the length parameter, and send either a URL or raw text. Upon firing and receiving a response from the API, the background script will update the DOM by sending messages to listeners on the content script.

background.js

let failure_message = "Unable to summarize text.";
let failure_message_blank = "To generate a summary, increase the summary length through the extension toolbar settings.";
let url = "";
let request;
let injected_tabs = []
chrome.runtime.onInstalled.addListener(function() {
    let contextMenuItem = {
        id: "summarize",
        title: "Blinknotes",
        contexts: ["page", "selection"]
    };
    chrome.contextMenus.create(contextMenuItem);
});
chrome.contextMenus.onClicked.addListener(function(info, tab) {
    let length = "0.1";
    chrome.storage.local.get('length', function(data) {
        length = (data.length / 20).toString();
    });
    chrome.tabs.query({
        active: true,
        lastFocusedWindow: true
    }, tabs => {
        url = tabs[0].url;
    });
    if (info.menuItemId == "summarize") {
        chrome.storage.local.get('in_progress', function(data) {
            if (data.in_progress == undefined || data.in_progress == false) {
                chrome.storage.local.set({
                    in_progress: true
                }, function() {
                    chrome.tabs.sendMessage(tab.id, {
                        name: "create_window",
                        content: {}
                    }, {}, function(res) {});
                    chrome.tabs.executeScript({
                        code: "window.getSelection().toString();"
                    }, function(selection) {
                        if (isNaN(length)) length = "0.1";
                        if (selection == "") {
                            request = new XMLHttpRequest();
                            request.open("POST", "https://text-summarize-api.herokuapp.com/url/", true);
                            request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
                            let params = "url=" + url + "&length=" + length;
                            request.send(params);
                            request.onreadystatechange = function() {
                                if (request.readyState == 4 && request.status == 200) {
                                    var response = request.responseText;
                                    let a = response.split("\n");
                                    let first_paragraph = "";
                                    if (a[1] == "") {
                                        first_paragraph = a[0];
                                        response = "";
                                        for (let i = 1; i < a.length; i++) response += a[i];
                                    }
                                    if (first_paragraph != "")
                                        first_paragraph = first_paragraph.replace(/(\r\n|\n|\r)/gm, "");
                                    response = response.replace(/(\r\n|\n|\r)/gm, "");
                                    if (response == "" && first_paragraph == "")
                                        chrome.tabs.sendMessage(tab.id, {
                                            name: "request_succeed",
                                            first_paragraph: "",
                                            content: failure_message_blank
                                        }, {}, function(res) {
                                            chrome.storage.local.set({
                                                in_progress: false
                                            }, function() {});
                                        });
                                    else
                                        chrome.tabs.sendMessage(tab.id, {
                                            name: "request_succeed",
                                            first_paragraph: first_paragraph,
                                            content: response
                                        }, {}, function(res) {
                                            chrome.storage.local.set({
                                                in_progress: false
                                            }, function() {});
                                        });
                                } else if (request.readyState == 4) {
                                    chrome.tabs.sendMessage(tab.id, {
                                        name: "request_failed",
                                        first_paragraph: "",
                                        content: failure_message
                                    }, {}, function(res) {
                                        chrome.storage.local.set({
                                            in_progress: false
                                        }, function() {});
                                    });
                                }
                            }
                        } else {
                            if (isNaN(length)) length = "0.1"
                            request = new XMLHttpRequest();
                            request.open("POST", "https://text-summarize-api.herokuapp.com/text/", true);
                            request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
                            let params = "text=" + encodeURIComponent(selection) + "&length=" + length;
                            request.send(params);
                            request.onreadystatechange = function() {
                                if (request.readyState == 4 && request.status == 200) {
                                    var response = request.responseText;
                                    response = response.replace(/(\r\n|\n|\r)/gm, "");
                                    if (response == "")
                                        chrome.tabs.sendMessage(tab.id, {
                                            name: "request_succeed",
                                            first_paragraph: "",
                                            content: failure_message_blank
                                        }, {}, function(res) {
                                            chrome.storage.local.set({
                                                in_progress: false
                                            }, function() {});
                                        });
                                    else
                                        chrome.tabs.sendMessage(tab.id, {
                                            name: "request_succeed",
                                            first_paragraph: "",
                                            content: response
                                        }, {}, function(res) {
                                            chrome.storage.local.set({
                                                in_progress: false
                                            }, function() {});
                                        });
                                } else if (request.readyState == 4) {
                                    chrome.tabs.sendMessage(tab.id, {
                                        name: "request_failed",
                                        first_paragraph: "",
                                        content: failure_message
                                    }, {}, function(res) {
                                        chrome.storage.local.set({
                                            in_progress: false
                                        }, function() {});
                                    });
                                }
                            }
                        }
                    });
                });
            }
        });
    }
});
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
    if (injected_tabs.includes(tabId)) return;
    injected_tabs.push(tabId)
    chrome.tabs.executeScript({
        file: 'scripts/content.js'
    }, _ => {
        chrome.runtime.lastError;
        injected_tabs = injected_tabs.filter(item => item !== tabId)
    })
});
chrome.tabs.onRemoved.addListener(function(tabId, removeInfo) {
    injected_tabs = injected_tabs.filter(item => item !== tabId)
});
Enter fullscreen mode Exit fullscreen mode

Content Script

Alt Text
A content script can be run on any single tab, and has access to its page’s DOM. Content scripts are either injected into the tab through the background script, or defined in the manifest to automatically run on a specific URL pattern. For this app we do manual injections, using the content script to create and update a temporary modal element on the page.

This content script creates a listener that handles a draggable modal on the page. Depending on the type of message sent to the listener, create_window, request_succeed, or request_failed, the listener will create the modal in a default loading state, update it from loading to success, or loading to failure.

content.js

if (typeof modal_x === 'undefined') {
    let modal_x = "";
    let modal_y = "";
    let styleString = `.chrome-extension-modal-content{background-color:#fefefe;margin:auto;position:absolute;z-index:999998;padding:5px;border:1px solid #888;width:40%;justify-content:center;align-items:center;overflow:auto;max-height:500px}.chrome-extension-modal-content p{padding:30px;font-size:15px;font-family:Calibri,Candara,Segoe,Segoe UI,Optima,Arial,sans-serif}.chrome-extension-modal-loading{display:flex;justify-content:center;align-items:center}.chrome-extension-modal-loading .dot{position:relative;width:.5em;height:.5em;margin:.3em;border-radius:50%;padding:0}.chrome-extension-modal-loading .dot::before{position:absolute;content:"";width:100%;height:100%;background:inherit;border-radius:inherit;animation:wave 2s ease-out infinite}.chrome-extension-modal-loading .dot:nth-child(1){background:#7ef9ff}.chrome-extension-modal-loading .dot:nth-child(1)::before{animation-delay:.2s}.chrome-extension-modal-loading .dot:nth-child(2){background:#89cff0}.chrome-extension-modal-loading .dot:nth-child(2)::before{animation-delay:.4s}.chrome-extension-modal-loading .dot:nth-child(3){background:#4682b4}.chrome-extension-modal-loading .dot:nth-child(3)::before{animation-delay:.6s}.chrome-extension-modal-loading .dot:nth-child(4){background:#0f52ba}.chrome-extension-modal-loading .dot:nth-child(4)::before{animation-delay:.8s}.chrome-extension-modal-loading .dot:nth-child(5){background:navy}.chrome-extension-modal-loading .dot:nth-child(5)::before{animation-delay:1s}@keyframes wave{50%,75%{transform:scale(2.5)}100%,80%{opacity:0}}.chrome-extension-close{color:#aaa;background-color:#fff;float:right;font-size:28px;font-weight:700;padding:10px}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer}`;
    let modal_inner_html_string = `<button class="chrome-extension-close">&times;</button> <br> <br> <br> <br><div class="chrome-extension-modal-loading"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="dot"></div></div> <br> <br> <br> <br>`;
    let modal_html_string = `<div class="chrome-extension-modal-content" >` + modal_inner_html_string +` </div>`;
    const dragElement = function(elmnt) {
        var pos1 = 0,
            pos2 = 0,
            pos3 = 0,
            pos4 = 0;
        elmnt.onmousedown = dragMouseDown;
        elmnt.style.left = modal_x + "px";
        elmnt.style.top = modal_y + "px";

        function dragMouseDown(e) {
            e = e || window.event;
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e = e || window.event;
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
            elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }
    const fadeOutLoader = function(callback) {
        var fadeTarget = document.getElementsByClassName("chrome-extension-modal-loading")[0];
        if (fadeTarget === undefined) return;
        if (!fadeTarget.style.opacity) {
            fadeTarget.style.opacity = 1;
        } else
            fadeTarget.style.opacity = 1;
        var fadeEffect = setInterval(function() {
            if (fadeTarget.style.opacity > 0) {
                fadeTarget.style.opacity -= 0.1;
            } else {
                clearInterval(fadeEffect);
                fadeTarget.remove();
                callback();
            }
        }, 100);
    }
    const createElementFromHTML = function(htmlString) {
        var div = document.createElement('div');
        div.innerHTML = htmlString.trim();
        return div.firstChild;
    }
    const addStyle = function(styleString) {
        const style = document.createElement('style');
        style.textContent = styleString;
        document.head.append(style);
    }
    var listener = function(request, options, sendResponse) {
        var display_result = function() {
            var modal_content = document.getElementsByClassName("chrome-extension-modal-content")[0];
            if (request.first_paragraph != "" && request.content != "")
                modal_content.innerHTML = "<button class='chrome-extension-close'>&times;</button>" + "<p>" + request.first_paragraph + "<br><br>" + request.content + "</p>";
            else if (request.content != "")
                modal_content.innerHTML = "<button class='chrome-extension-close'>&times;</button>" + "<p>" + request.content + "</p>";
            var span = document.getElementsByClassName("chrome-extension-close")[0];
            span.onclick = function() {
                modal_content.style.display = "none";
            };
        };
        if (request.name == "create_window") {
            addStyle(styleString);
            modal_content = document.getElementsByClassName("chrome-extension-modal-content")[0];
            if (modal_content == null) {
                let modal_element = createElementFromHTML(modal_html_string);
                document.body.append(modal_element);
            } else {
                modal_content.innerHTML = modal_inner_html_string;
            }
            var span = document.getElementsByClassName("chrome-extension-close")[0];
            span.onclick = function() {
                chrome.storage.local.set({
                    in_progress: false
                }, function() {
                });
                modal_content.style.display = "none";
            };
            var modal_content = document.getElementsByClassName("chrome-extension-modal-content")[0];
            modal_content.style.display = "block";
            dragElement(modal_content);
            sendResponse();
        } else if (request.name == "request_failed") {
            fadeOutLoader(display_result);
            sendResponse();
        } else if (request.name == "request_succeed") {
            fadeOutLoader(display_result);
            sendResponse();
        } else sendResponse();
        return true;
    }
    document.addEventListener("contextmenu", function(event) {
        modal_x = event.pageX;
        modal_y = event.pageY;
        if (!chrome.runtime.onMessage.hasListener(listener)) {
            chrome.runtime.onMessage.addListener(listener);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Testing

Alt Text
Alt Text

Edge Cases

There are several cases where the app may generate an unexpected error or get caught in an infinite loop. This section will contain a short description of how each one is handled in background.js and content.js.

One error occurs due to API calls being asynchronous and time-gated. You can have multiple calls pending simultaneously from multiple tabs — while this is possible to handle, it requires significantly more work. For this situation, we limit the app to a maximum of 1 call at a time. We use storage to keep track of the boolean in_progress. Set it to true when the call is fired, set it back to false after the entire process is complete. Any attempts to re-call the API while in_progress is true is nullified.

Another error occurs when injecting duplicate content scripts on the same page. We can solve this by keeping track of an injected_tabs array in the background script, and only injecting if it does not contain the tab id. As an added measure, we also do a null check at the top of the content script.

One last error occurs when a content script generates an exception that interrupts the normal flow of the app. Add a callback with every injection, _=>{chrome.runtime.lastError, that will simply read and ignore this type of error instead of passing it down the call stack.

You can get creative with bug fixes, handling your edge cases with unique and hacky solutions. Just be sure not to stray too far from the overall app design, and test thoroughly before publishing.

Conclusion

There are surprisingly few limitations to what a Chrome extension can do, with a vast ecosystem of apps in the Chrome Web Store. This guide attempts to outline key points from the official documentation. When you are ready to publish, you can use similar apps to provide guidelines on how to market your app.

Discussion (1)

pic
Editor guide
Collapse
tilakmaddy_68 profile image
Tilak Madichetti

OP : "complete guide"
Also OP: drops a bunch of code in my face w/o explanation 😂