diff --git a/functional-samples/sample.text-replacer/README.md b/functional-samples/sample.text-replacer/README.md new file mode 100644 index 0000000000..b116d6455e --- /dev/null +++ b/functional-samples/sample.text-replacer/README.md @@ -0,0 +1,18 @@ +# Text Replacer + +This sample demonstrates how an extension can replace text on a page in response to user invocation. + +## Overview + +Users can use the extension's popup to define up to 5 pairs of text replacements. Text is replaced when the user clicks the "Replace" button in the popup, when they select the "Replace text" context menu option on a page, or when they use the extension's keyboard shortcut (command). + +## Compatibility + +`"minimum_chrome_version"` is set to 148 because that version adds support for returning a promise from an `onMessage` event handler ([docs](https://developer.chrome.com/docs/extensions/develop/concepts/messaging#:~:text=call%20it%20later.-,Return%20a%20promise,-From%20Chrome%20148)) to respond to a message asynchronously. + +## Running this extension + +1. Clone this repository. +2. Load this directory in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked). +3. Navigate to any page (make sure that the URL doesn't start with `chrome://`). +4. Click the extension icon. diff --git a/functional-samples/sample.text-replacer/background.js b/functional-samples/sample.text-replacer/background.js new file mode 100644 index 0000000000..a7454d05e7 --- /dev/null +++ b/functional-samples/sample.text-replacer/background.js @@ -0,0 +1,70 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const REPLACE_TEXT_MENUITEM_ID = 'replace-text-menuitem'; +const REPLACE_TEXT_COMMAND_ID = 'replace-text-command'; +const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; +const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; + +chrome.runtime.onInstalled.addListener(async () => { + // Remove any previously registered context menus to avoid conflicts + await chrome.contextMenus.removeAll(); + + // Register the new set of context menus + await chrome.contextMenus.create({ + id: REPLACE_TEXT_MENUITEM_ID, + title: 'Replace text' + }); +}); + +chrome.commands.onCommand.addListener((command, tab) => { + if (command == REPLACE_TEXT_COMMAND_ID) { + replaceText(tab.id); + } else { + throw new Error(`Unknown command executed with ID "${command}"`); + } +}); + +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId == REPLACE_TEXT_MENUITEM_ID) { + replaceText(tab.id); + } else { + throw new Error( + `Unknown context menu option clicked with ID "${info.menuItemId}"` + ); + } +}); + +// Route all storage reads/writes through the background so we have a single +// source of truth +chrome.runtime.onMessage.addListener((message) => { + switch (message.id) { + case GET_REPLACEMENTS_MESSAGE_ID: + // Fall back to an empty array 'patterns' is not set + return chrome.storage.sync.get({ patterns: [] }); + + case SET_REPLACEMENTS_MESSAGE_ID: + return chrome.storage.sync.set({ patterns: message.data }); + + default: + throw new Error(`Unknown message received with ID "${message.id}"`); + } +}); + +function replaceText(tabId) { + chrome.scripting.executeScript({ + target: { tabId }, + files: ['content.js'] + }); +} diff --git a/functional-samples/sample.text-replacer/content.js b/functional-samples/sample.text-replacer/content.js new file mode 100644 index 0000000000..4d25a59b25 --- /dev/null +++ b/functional-samples/sample.text-replacer/content.js @@ -0,0 +1,65 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Wrap the in an immediately invoked function expression (IIFE) in order to +// prevent local variables from polluting global scope +(function () { + const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; + + function buildReplacementRegex(source = []) { + const output = source + .filter(([find] = []) => find) + .map(([find, replace]) => { + const sanitizedMatch = escapeRegExp(find); + const findExp = new RegExp(`\\b${sanitizedMatch}\\b`, 'gi'); + return [findExp, replace]; + }); + return output; + } + + // For example purposes only. This may not cover all special characters used + // in regular repressions. + const 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) { + const nodeIterator = document.createNodeIterator( + document.body, + NodeFilter.SHOW_TEXT + ); + + let node; + while ((node = nodeIterator.nextNode())) { + for (let [find, replace] of replacements) { + // Guard against nullish replacement values + replace ??= ''; + node.nodeValue = node.nodeValue.replace(find, replace); + } + } + } + + // Get the patterns to replace from storage, then build a regex from them and + // replace all text on the page. + chrome.runtime + .sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) + .then((data) => { + const replacementPatterns = buildReplacementRegex(data.patterns); + replaceText(replacementPatterns); + }); +})(); diff --git a/functional-samples/sample.text-replacer/icons/icon128.png b/functional-samples/sample.text-replacer/icons/icon128.png new file mode 100644 index 0000000000..4eb8de32db Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon128.png differ diff --git a/functional-samples/sample.text-replacer/icons/icon16.png b/functional-samples/sample.text-replacer/icons/icon16.png new file mode 100644 index 0000000000..754be60cc6 Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon16.png differ diff --git a/functional-samples/sample.text-replacer/icons/icon24.png b/functional-samples/sample.text-replacer/icons/icon24.png new file mode 100644 index 0000000000..da0aab2e79 Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon24.png differ diff --git a/functional-samples/sample.text-replacer/icons/icon32.png b/functional-samples/sample.text-replacer/icons/icon32.png new file mode 100644 index 0000000000..de99ca80d0 Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon32.png differ diff --git a/functional-samples/sample.text-replacer/icons/icon48.png b/functional-samples/sample.text-replacer/icons/icon48.png new file mode 100644 index 0000000000..6b1496dc73 Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon48.png differ diff --git a/functional-samples/sample.text-replacer/icons/icon64.png b/functional-samples/sample.text-replacer/icons/icon64.png new file mode 100644 index 0000000000..ed8e84a149 Binary files /dev/null and b/functional-samples/sample.text-replacer/icons/icon64.png differ diff --git a/functional-samples/sample.text-replacer/manifest.json b/functional-samples/sample.text-replacer/manifest.json new file mode 100644 index 0000000000..49c11f1cc8 --- /dev/null +++ b/functional-samples/sample.text-replacer/manifest.json @@ -0,0 +1,45 @@ +{ + "name": "Text Replacer", + "version": "1.0.0", + "manifest_version": 3, + "description": "Replace any line of text on a page with another line of text.", + "minimum_chrome_version": "148", + "icons": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png", + "128": "icons/icon128.png" + }, + "action": { + "default_title": "Manage text replacements", + "default_icon": { + "16": "icons/icon16.png", + "24": "icons/icon24.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "64": "icons/icon64.png" + }, + "default_popup": "popup.html" + }, + "permissions": [ + "scripting", + "activeTab", + "storage", + "commands", + "contextMenus" + ], + "background": { + "service_worker": "background.js" + }, + "commands": { + "replace-text-command": { + "description": "Replace text on the current page.", + "suggested_key": { + "default": "Alt+R", + "mac": "MacCtrl+R" + } + } + } +} diff --git a/functional-samples/sample.text-replacer/popup.css b/functional-samples/sample.text-replacer/popup.css new file mode 100644 index 0000000000..b24f30000d --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.css @@ -0,0 +1,99 @@ +/* +Copyright 2026 Google LLC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +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/functional-samples/sample.text-replacer/popup.html b/functional-samples/sample.text-replacer/popup.html new file mode 100644 index 0000000000..0a68a7e59e --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.html @@ -0,0 +1,88 @@ + + + + + + + + Document + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FindReplace
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ +
+ + +
+
+ + + + + diff --git a/functional-samples/sample.text-replacer/popup.js b/functional-samples/sample.text-replacer/popup.js new file mode 100644 index 0000000000..286e6e05a7 --- /dev/null +++ b/functional-samples/sample.text-replacer/popup.js @@ -0,0 +1,114 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const GET_REPLACEMENTS_MESSAGE_ID = 'get-replacements'; +const SET_REPLACEMENTS_MESSAGE_ID = 'set-replacements'; + +const form = document.querySelector('form'); + +// Ask the background for replacement patterns and initialize the page +chrome.runtime + .sendMessage({ id: GET_REPLACEMENTS_MESSAGE_ID }) + .then((data) => loadFormData(data.patterns)); + +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('reset').addEventListener('click', (_event) => { + // automatically reset the form's contents, but those + // changes won't settle until the the next turn of the event loop. Since + // saveFormData works directly gainst DOM, we have to call it after a minimal + // delay. + setTimeout(saveFormData, 0); +}); + +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 = Math.floor(index / 2); + 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; + }, []); + + // Ask the background to persist this data + chrome.runtime.sendMessage({ + id: SET_REPLACEMENTS_MESSAGE_ID, + data: 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; +}