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
+
+
+
+
+
+
+
+
+
+
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);
+ }
+ });
+ });
+}