-
Notifications
You must be signed in to change notification settings - Fork 8.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Text Replacer example #612
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. extract "replace-text-menuitem" into a constant |
||
replaceText(tab.id); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd normally recommend throwing an assert in here, since this is the only id we ever expect. Similarly for commands. |
||
}); | ||
|
||
function replaceText(tabId) { | ||
chrome.scripting.executeScript({ | ||
target: {tabId}, | ||
files: ['content.js'], | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think having this method is a bit confusing. I'd just move its two lines into the callback for the fetcher, and expand the comment: // Get the patterns to replace from storage, then build a regex from them and replace all text on the page. |
||||||
const replacementPatterns = buildReplacementRegex(replacements); | ||||||
replaceText(replacementPatterns); | ||||||
} | ||||||
|
||||||
function buildReplacementRegex(source) { | ||||||
const output = []; | ||||||
for (var i = 0; i < source.length; i++) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh wow. I didn't realise this runs non-ESM code and runs in the top-scope. Everything in this file is being redeclared always. Would it be worth putting it in an IIFE? Is that too complex? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 |
||||||
// 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, "\\$&") | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I highly suspect this might not be all regex chars. Maybe call out that this is for example purposes only? |
||||||
} | ||||||
|
||||||
/** 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()) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we declare node here? We only need it in the body |
||||||
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); | ||||||
}); | ||||||
Comment on lines
+45
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're going to be gradually encouraging developers to avoid storage access by content scripts unless crucial. WDYT about changing this to a message to the background page? (I don't feel strongly) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
{ | ||
"name": "Text Replacer", | ||
"version": "1.0.0", | ||
"manifest_version": 3, | ||
"icons": { | ||
"32": "icons/icon32.png", | ||
"48": "icons/icon48.png", | ||
"64": "icons/icon64.png" | ||
}, | ||
"action": { | ||
"default_title": "Show an alert", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this 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." | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doesn't this need a default keybinding? |
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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%); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
<!DOCTYPE html> | ||
<!-- | ||
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 lang="en"> | ||
|
||
<head> | ||
<meta charset="UTF-8"> | ||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> | ||
<title>Document</title> | ||
<link rel="stylesheet" href="popup.css"> | ||
</head> | ||
|
||
<body> | ||
<form> | ||
<table class="form--table"> | ||
<thead> | ||
<tr> | ||
<th>Find</th> | ||
<th>Replace</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
</tr> | ||
<tr> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
<td> | ||
<input type="text" class="form--input"> | ||
</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
|
||
<div class="form--controls"> | ||
<input id="clear" type="button" value="Clear"> | ||
<input id="submit" type="submit" value="Replace"> | ||
</div> | ||
</form> | ||
|
||
<script src="popup.js"></script> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you use |
||
</body> | ||
|
||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can get rid of this and change the |
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do a real division and round it. Sorry, but we should be clear to novices. |
||
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); | ||
} | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
During extension update, will this cause an error by trying to re-register context menu items?