diff --git a/README.md b/README.md index 581c2dccaf..1a0d13279e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,20 @@ Read more on [Getting Started](https://developer.chrome.com/extensions/getstarte + + + My Bookmarks
+ examples/bookmarks + + + + + Page Redder
@@ -56,15 +70,20 @@ Read more on [Getting Started](https://developer.chrome.com/extensions/getstarte - My Bookmarks
- examples/bookmarks + Text Replacer
+ examples/text-replacer diff --git a/examples/text-replacer/background.js b/examples/text-replacer/background.js new file mode 100644 index 0000000000..4e51282679 --- /dev/null +++ b/examples/text-replacer/background.js @@ -0,0 +1,29 @@ +chrome.commands.onCommand.addListener((command, tab) => { + if (command == 'replace-text') { + replaceText(tab.id); + } +}); + +chrome.runtime.onInstalled.addListener(() => { + registerContextMenus(); +}); + +function registerContextMenus() { + chrome.contextMenus.create({ + id: 'replace-text-menuitem', + title: 'Replace text', + }); +} + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId == 'replace-text-menuitem') { + replaceText(tab.id); + } +}); + +function replaceText(tabId) { + chrome.scripting.executeScript({ + target: {tabId}, + files: ['content.js'], + }); +} diff --git a/examples/text-replacer/content.js b/examples/text-replacer/content.js new file mode 100644 index 0000000000..98d92bcdba --- /dev/null +++ b/examples/text-replacer/content.js @@ -0,0 +1,47 @@ +// Copyright 2021 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +// Replace text on the page using a static list of patterns +function textReplacer(replacements) { + const replacementPatterns = buildReplacementRegex(replacements); + replaceText(replacementPatterns); +} + +function buildReplacementRegex(source) { + const output = []; + for (var i = 0; i < source.length; i++) { + if (!source[i]) { continue; } + const [find, replace] = source[i]; + const sanitizedMatch = escapeRegExp(find); + const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); + output[i] = [findExp, replace]; + } + return output; +} + +// Use var to avoid "Identifier 'REGEXP_SPECIAL_CHARACTERS' has already been +// declared" errors when running multiple times on the same page. +var REGEXP_SPECIAL_CHARACTERS = /[.(){}^$*+?[\]\\]/g; +/** Sanitize user input to prevent unexpected behavior during RegExp execution */ +function escapeRegExp(pattern) { + return pattern.replace(REGEXP_SPECIAL_CHARACTERS, "\\$&") +} + +/** Iterate through all text nodes and replace */ +function replaceText(replacements) { + let node; + const nodeIterator = document.createNodeIterator(document.body, NodeFilter.SHOW_TEXT); + while (node = nodeIterator.nextNode()) { + for (let [find, replace] of replacements) { + node.nodeValue = node.nodeValue.replace(find, replace); + } + } +} + +// Replace text on the page using a list of patterns loaded from storage +chrome.storage.sync.get(['patterns'], function(data) { + textReplacer(data.patterns); +}); diff --git "a/examples/text-replacer/icons/Icon\r" "b/examples/text-replacer/icons/Icon\r" new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/text-replacer/icons/Icon24.png b/examples/text-replacer/icons/Icon24.png new file mode 100644 index 0000000000..da0aab2e79 Binary files /dev/null and b/examples/text-replacer/icons/Icon24.png differ diff --git a/examples/text-replacer/icons/icon128.png b/examples/text-replacer/icons/icon128.png new file mode 100644 index 0000000000..4eb8de32db Binary files /dev/null and b/examples/text-replacer/icons/icon128.png differ diff --git a/examples/text-replacer/icons/icon16.png b/examples/text-replacer/icons/icon16.png new file mode 100644 index 0000000000..754be60cc6 Binary files /dev/null and b/examples/text-replacer/icons/icon16.png differ diff --git a/examples/text-replacer/icons/icon19.png b/examples/text-replacer/icons/icon19.png new file mode 100644 index 0000000000..7452a65854 Binary files /dev/null and b/examples/text-replacer/icons/icon19.png differ diff --git a/examples/text-replacer/icons/icon32.png b/examples/text-replacer/icons/icon32.png new file mode 100644 index 0000000000..de99ca80d0 Binary files /dev/null and b/examples/text-replacer/icons/icon32.png differ diff --git a/examples/text-replacer/icons/icon48.png b/examples/text-replacer/icons/icon48.png new file mode 100644 index 0000000000..6b1496dc73 Binary files /dev/null and b/examples/text-replacer/icons/icon48.png differ diff --git a/examples/text-replacer/icons/icon64.png b/examples/text-replacer/icons/icon64.png new file mode 100644 index 0000000000..ed8e84a149 Binary files /dev/null and b/examples/text-replacer/icons/icon64.png differ diff --git a/examples/text-replacer/manifest.json b/examples/text-replacer/manifest.json new file mode 100644 index 0000000000..90468c2149 --- /dev/null +++ b/examples/text-replacer/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Text Replacer", + "version": "1.0.0", + "manifest_version": 3, + "description": "Replace any a line of text on a page with another line of text.", + "icons": { + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png" + }, + "action": { + "default_title": "Show an alert", + "default_icon": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", + "32": "icons/icon32.png" + }, + "default_popup": "popup.html" + }, + "permissions": [ + "scripting", + "activeTab", + "storage", + "commands", + "contextMenus" + ], + "background": { + "service_worker": "background.js" + }, + "commands": { + "replace-text": { + "description": "Replace text on the current page." + } + } +} diff --git a/examples/text-replacer/popup.css b/examples/text-replacer/popup.css new file mode 100644 index 0000000000..fc57133313 --- /dev/null +++ b/examples/text-replacer/popup.css @@ -0,0 +1,91 @@ +/* +Copyright 2021 Google LLC + +Use of this source code is governed by a BSD-style +license that can be found in the LICENSE file or at +https://developers.google.com/open-source/licenses/bsd +*/ + +html, body { + height: 200px; + width: 400px; + margin: 0; + padding: 0; + font-size: 16px; +} + +body { + padding-bottom: 2em; +} + +.form--table { + width: 100%; + border-collapse: collapse; +} + +.form--input { + width: 100%; + padding: .25rem .5rem; + background: #eee; + box-sizing: border-box; + border: 1px solid #0004; + line-height: 1.5; +} + +th { + text-transform: uppercase; + font-size: .7rem; + color: hsl(0, 0%, 30%); +} + +td { + padding: .25rem .1rem; +} + +td:first-child { + padding-left: .25rem; +} +td:last-child { + padding-right: .25rem; +} + +.form--controls { + position: fixed; + display: flex; + bottom: 0; + right: 0; + left: 0; +} +.form--controls > * { + flex: 1; +} + +.form--controls > * { + border: none; + height: 2rem; + margin: .1rem; + cursor: pointer; + border: 1px solid hsla(0, 0%, 0%, .2); +} + +.form--controls > *:first-child { + margin-left: .25rem; +} +.form--controls > *:last-child { + margin-right: .25rem; +} + +#clear { + background: hsl(0, 0%, 100%); +} +#clear:hover, +#clear:focus { + background: hsl(0, 0%, 80%); +} +#submit { + background: hsl(190, 100%, 60%); +} +#submit:hover, +#submit:focus { + background: hsl(190, 80%, 50%); +} diff --git a/examples/text-replacer/popup.html b/examples/text-replacer/popup.html new file mode 100644 index 0000000000..1c4820501d --- /dev/null +++ b/examples/text-replacer/popup.html @@ -0,0 +1,80 @@ + + + + + + + + Document + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FindReplace
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+
+ + + + + diff --git a/examples/text-replacer/popup.js b/examples/text-replacer/popup.js new file mode 100644 index 0000000000..7adb45ad02 --- /dev/null +++ b/examples/text-replacer/popup.js @@ -0,0 +1,130 @@ +// Copyright 2021 Google LLC +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +const form = document.querySelector('form'); + +load(['patterns']) + .then(data => data.patterns) + .then(loadFormData); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveFormData(); + + let currentTab = await getCurrentTab(); + chrome.scripting.executeScript({ + target: {tabId: currentTab.id}, + files: ['content.js'] + }); +}); + +document.getElementById('clear').addEventListener('click', (event) => { + form.querySelectorAll('input[type=text]').forEach(el => el.value = ''); + saveFormData(); +}); + +form.addEventListener('input', debounce(saveFormData, 250)); + +/** + * Populate the form with values from persistent storage. + */ +function loadFormData(patterns) { + const inputs = form.querySelectorAll('input[type=text]'); + const flatPatterns = patterns.flat(); + + inputs.forEach((input, index) => { + input.value = flatPatterns[index] || ''; + }); +} + +/** + * Write the form values to persistent storage. + */ +function saveFormData() { + const inputs = form.querySelectorAll('input[type=text]'); + + const patterns = [...inputs].reduce((acc, input, index) => { + const outerIndex = index >> 1; + const innerIndex = index % 2; + + if (innerIndex === 0) { + // Set the "find" value + acc[outerIndex] = [input.value, '']; + } else { + // Set the "replace" value + acc[outerIndex][innerIndex] = input.value; + } + + return acc; + }, []); + + return save({patterns}); +} + +/** + * Limits how often the supplied callback function will be called. + * + * @see https://developers.google.com/web/fundamentals/performance/rendering/debounce-your-input-handlers + * + * @param {function} fn Callback function that you want to debounce. + * @param {number} wait The amount of time to wait before calling the function. + */ +function debounce(fn, wait = 100) { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + timeout = null; + fn.apply(this, args); + }, wait); + } +} + +/** + * Fetch the currently active tab. + * + * @returns chrome.tabs.Tab instance + */ +async function getCurrentTab() { + let queryOptions = { active: true, currentWindow: true }; + let [tab] = await chrome.tabs.query(queryOptions); + return tab; +} + + +/** + * Minimal promise wrapper for chrome.storage.sync.set(). + * + * @param {object} data Object containing key-value pairs of data to persist. + */ +async function save(data) { + return new Promise((resolve, reject) => { + chrome.storage.sync.set(data, (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(result); + } + }); + }); +} + +/** + * Minimal promise wrapper for chrome.storage.sync.get(). + * + * @param {string[]} keys Array of keys to retrieve from storage. + */ +function load(keys) { + return new Promise((resolve, reject) => { + chrome.storage.sync.get(keys, (data) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(data); + } + }); + }); +}