├── .gitignore ├── fonts ├── ComicSansMS.ttf ├── Nunito-Medium.ttf ├── Roboto-Regular.ttf ├── Boldonse-Regular.ttf ├── SourGummy-Regular.ttf └── OpenDyslexic-Regular.otf ├── package.json ├── manifest.json ├── README.md ├── server.js ├── background.js ├── popup.html ├── styles.css ├── popup.js └── content.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env -------------------------------------------------------------------------------- /fonts/ComicSansMS.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/ComicSansMS.ttf -------------------------------------------------------------------------------- /fonts/Nunito-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/Nunito-Medium.ttf -------------------------------------------------------------------------------- /fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /fonts/Boldonse-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/Boldonse-Regular.ttf -------------------------------------------------------------------------------- /fonts/SourGummy-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/SourGummy-Regular.ttf -------------------------------------------------------------------------------- /fonts/OpenDyslexic-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xStylistic/text-savvy/HEAD/fonts/OpenDyslexic-Regular.otf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textsavvy-backend", 3 | "version": "1.0.0", 4 | "description": "Backend service for TextSavy Chrome extension", 5 | "main": "server.js", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "start": "node server.js", 11 | "dev": "nodemon server.js" 12 | }, 13 | "dependencies": { 14 | "cors": "^2.8.5", 15 | "dotenv": "^16.0.3", 16 | "express": "^4.18.2", 17 | "node-fetch": "^2.6.9" 18 | }, 19 | "devDependencies": { 20 | "nodemon": "^2.0.22" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "TextSavvy - Web browsing made easy for all", 4 | "version": "1.0", 5 | "permissions": ["contextMenus", "scripting", "activeTab", "storage"], 6 | "action": { 7 | "default_popup": "popup.html" 8 | }, 9 | "background": { 10 | "service_worker": "background.js" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": [""], 15 | "js": ["content.js"] 16 | } 17 | ], 18 | "web_accessible_resources": [ 19 | { 20 | "resources": ["fonts/*.otf", "fonts/*.ttf"], 21 | "matches": [""] 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TextSavvy 2 | 3 | A chrome extension to help make web browsing accessible for everyone :) 4 | 5 | ## Installation 6 | 7 | Clone or fork this repo and then install dependencies. 8 | ```bash 9 | npm install 10 | ``` 11 | If you want to use your own API key, create an .env file like this: 12 | ``` 13 | COHERE_API_KEY= 14 | PORT= 15 | ``` 16 | 17 | ## Usage 18 | 19 | Go to **chrome://extensions/** and make sure you're in developer mode. Click **load unpacked** and select the folder. You can run this extension with your own API key or use our backend server. You should now be able to use the extension :) 20 | 21 | ## Contributing 22 | 23 | Pull requests are welcome. For major changes, please open an issue first 24 | to discuss what you would like to change! 25 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const fetch = require('node-fetch'); 4 | require('dotenv').config(); 5 | 6 | const app = express(); 7 | 8 | // Configure CORS with specific options 9 | app.use(cors({ 10 | origin: true, // Allow all origins 11 | methods: ['GET', 'POST', 'OPTIONS'], 12 | allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], 13 | credentials: true 14 | })); 15 | 16 | // Handle preflight requests 17 | app.options('*', cors()); 18 | 19 | // Parse JSON bodies 20 | app.use(express.json()); 21 | 22 | const COHERE_API_KEY = process.env.COHERE_API_KEY; 23 | 24 | // Health check endpoint for Render 25 | app.get('/', (req, res) => { 26 | res.json({ status: 'ok', message: 'TextSavy API is running' }); 27 | }); 28 | 29 | app.post('/api/translate', async (req, res) => { 30 | try { 31 | const { text, language } = req.body; 32 | const response = await fetch("https://api.cohere.ai/v2/generate", { 33 | method: "POST", 34 | headers: { 35 | Authorization: `Bearer ${COHERE_API_KEY}`, 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify({ 39 | model: "command-r-plus", 40 | prompt: `Translate the following text to ${language}:\n\n${text}\n\nONLY OUTPUT THE TRANSLATED TEXT`, 41 | temperature: 0.7, 42 | }), 43 | }); 44 | const data = await response.json(); 45 | res.json({ text: data.generations[0].text.trim() }); 46 | } catch (error) { 47 | console.error('Translation error:', error); 48 | res.status(500).json({ error: error.message }); 49 | } 50 | }); 51 | 52 | app.post('/api/modify', async (req, res) => { 53 | try { 54 | const { text, prompt } = req.body; 55 | const response = await fetch("https://api.cohere.ai/v2/generate", { 56 | method: "POST", 57 | headers: { 58 | Authorization: `Bearer ${COHERE_API_KEY}`, 59 | "Content-Type": "application/json", 60 | }, 61 | body: JSON.stringify({ 62 | model: "command-r-plus", 63 | prompt: prompt.replace("{{text}}", text), 64 | temperature: 0.7, 65 | }), 66 | }); 67 | const data = await response.json(); 68 | res.json({ text: data.generations[0].text.trim() }); 69 | } catch (error) { 70 | console.error('Modification error:', error); 71 | res.status(500).json({ error: error.message }); 72 | } 73 | }); 74 | 75 | const PORT = 10000; 76 | app.listen(PORT, () => { 77 | console.log(`Server running on port ${PORT}`); 78 | }); -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // Create context menu items for text operations 2 | chrome.runtime.onInstalled.addListener(() => { 3 | // Create simplify text option 4 | chrome.contextMenus.create({ 5 | id: "simplifyText", 6 | title: "Simplify Text", 7 | contexts: ["selection"], 8 | }); 9 | 10 | // Create parent menu for translation 11 | chrome.contextMenus.create({ 12 | id: "translateText", 13 | title: "Translate Text", 14 | contexts: ["selection"], 15 | }); 16 | 17 | // Create language submenu items 18 | const languages = [ 19 | { id: "translateEnglish", title: "English", lang: "English" }, 20 | { id: "translateSpanish", title: "Spanish", lang: "Spanish" }, 21 | { id: "translateFrench", title: "French", lang: "French" }, 22 | { id: "translateGerman", title: "German", lang: "German" }, 23 | { id: "translateChinese", title: "Chinese", lang: "Chinese" }, 24 | { id: "translateJapanese", title: "Japanese", lang: "Japanese" }, 25 | { id: "translateKorean", title: "Korean", lang: "Korean" }, 26 | { id: "translateItalian", title: "Italian", lang: "Italian" }, 27 | { id: "translatePortuguese", title: "Portuguese", lang: "Portuguese" }, 28 | { id: "translateRussian", title: "Russian", lang: "Russian" }, 29 | ]; 30 | 31 | // Add each language as a submenu item 32 | languages.forEach((lang) => { 33 | chrome.contextMenus.create({ 34 | id: lang.id, 35 | parentId: "translateText", 36 | title: lang.title, 37 | contexts: ["selection"], 38 | }); 39 | }); 40 | 41 | // Create text-to-speech option 42 | chrome.contextMenus.create({ 43 | id: "speakText", 44 | title: "Speak Text", 45 | contexts: ["selection"], 46 | }); 47 | }); 48 | 49 | // Handle clicks on the context menu items 50 | chrome.contextMenus.onClicked.addListener((info, tab) => { 51 | if (info.menuItemId === "simplifyText") { 52 | chrome.tabs.sendMessage(tab.id, { 53 | action: "modifyPageText", 54 | prompt: 55 | "Rewrite the following to be simpler and easier to read. DO NOT RESPOND WITH ANYTHING ELSE BUT THE SIMPLIFIED TEXT. Here is the text you simplify:\n\n{{text}}\n DO NOT REPLY WITH 'Here is your simplified text:'", 56 | }); 57 | } 58 | // Handle translation menu items 59 | else if (info.menuItemId.startsWith("translate")) { 60 | const languageMap = { 61 | translateEnglish: "English", 62 | translateSpanish: "Spanish", 63 | translateFrench: "French", 64 | translateGerman: "German", 65 | translateChinese: "Chinese", 66 | translateJapanese: "Japanese", 67 | translateKorean: "Korean", 68 | translateItalian: "Italian", 69 | translatePortuguese: "Portuguese", 70 | translateRussian: "Russian", 71 | }; 72 | 73 | const language = languageMap[info.menuItemId]; 74 | if (language) { 75 | chrome.tabs.sendMessage(tab.id, { 76 | action: "translatePage", 77 | language: language, 78 | }); 79 | } 80 | } 81 | // Handle text-to-speech 82 | else if (info.menuItemId === "speakText") { 83 | chrome.tabs.sendMessage(tab.id, { 84 | action: "speakText", 85 | }); 86 | } 87 | }); 88 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

TextSavvy

9 | 10 |
11 | 12 |
13 |

accessibility modes

14 | 15 | 16 |
17 | 18 |
19 | 20 |
21 |

text customization

22 | 23 |
24 | 34 | 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 |
47 | 48 |
49 |

text tools

50 |
51 | 52 | 53 |
54 | 55 | 56 | 68 | 69 |
70 | 71 |
72 | 73 |
74 |

speech settings

75 | 78 | 79 | 80 | 83 | 84 |
85 | 86 |
87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Segoe UI", sans-serif; 3 | margin: 0; 4 | padding: 10px; 5 | background-color: #f4f1f0; 6 | width: 260px; 7 | } 8 | 9 | .container { 10 | background-color: #fff; 11 | border-radius: 12px; 12 | padding: 16px 12px; 13 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 14 | } 15 | 16 | h1.title { 17 | font-size: 20px; 18 | font-weight: 600; 19 | text-align: center; 20 | margin: 0 0 12px 0; 21 | color: #333; 22 | } 23 | 24 | .close-btn { 25 | position: absolute; 26 | right: 0; 27 | top: 0; 28 | font-size: 24px; 29 | cursor: pointer; 30 | padding: 0 8px; 31 | color: #666; 32 | } 33 | 34 | .close-btn:hover { 35 | color: #333; 36 | } 37 | 38 | h2 { 39 | font-size: 18px; 40 | font-weight: 400; 41 | text-transform: lowercase; 42 | margin: 12px 0 6px; 43 | text-align: center; 44 | } 45 | 46 | label { 47 | font-size: 15px; 48 | display: block; 49 | margin-top: 10px; 50 | margin-bottom: 4px; 51 | } 52 | 53 | select { 54 | width: 100%; 55 | padding: 8px 10px; 56 | font-size: 14px; 57 | border-radius: 6px; 58 | border: 1px solid #ccc; 59 | height: 38px; 60 | } 61 | 62 | input[type="range"] { 63 | width: 100%; 64 | margin-top: 4px; 65 | margin-bottom: 8px; 66 | } 67 | 68 | .btn { 69 | width: 100%; 70 | border: none; 71 | padding: 8px; 72 | margin-top: 6px; 73 | border-radius: 8px; 74 | font-size: 14px; 75 | cursor: pointer; 76 | } 77 | 78 | .btn.blue { 79 | background-color: #b2dbff; 80 | } 81 | 82 | .btn.yellow { 83 | background-color: #ffe1a1; 84 | } 85 | 86 | .btn.green { 87 | background-color: #b0f9b0; 88 | } 89 | 90 | .btn.pink { 91 | background-color: #fdd8e5; 92 | } 93 | 94 | .btn.purple { 95 | background-color: #ddc7ff; 96 | } 97 | 98 | .btn.grey { 99 | background-color: #e3e0e0; 100 | } 101 | 102 | .btn:hover { 103 | filter: brightness(95%); 104 | transition: filter 0.2s ease; 105 | } 106 | 107 | .btn:active { 108 | filter: brightness(85%); 109 | } 110 | 111 | .small-btn { 112 | height: 38px; 113 | padding: 0 14px; 114 | font-size: 14px; 115 | border-radius: 6px; 116 | background-color: #ddd; 117 | border: none; 118 | cursor: pointer; 119 | white-space: nowrap; 120 | flex-shrink: 0; 121 | display: flex; 122 | align-items: center; 123 | justify-content: center; 124 | } 125 | 126 | .small-btn:hover { 127 | background-color: #ccc; 128 | } 129 | 130 | .small-btn:active { 131 | background-color: #bbb; 132 | } 133 | 134 | .row { 135 | display: flex; 136 | gap: 6px; 137 | align-items: center; 138 | margin-bottom: 12px; 139 | } 140 | 141 | hr { 142 | border: none; 143 | border-top: 1px solid #ccc; 144 | margin: 16px -12px; 145 | width: calc(100% + 24px); 146 | } 147 | 148 | section { 149 | margin: 8px 0; 150 | } 151 | 152 | section > *:first-child { 153 | margin-top: 0; 154 | } 155 | 156 | @font-face { 157 | font-family: 'Boldonse'; 158 | src: url('fonts/Boldonse-Regular.ttf') format('truetype'); 159 | font-weight: normal; 160 | font-style: normal; 161 | } 162 | 163 | @font-face { 164 | font-family: 'ComicSans'; 165 | src: url('fonts/ComicSansMS.ttf') format('truetype'); 166 | font-weight: normal; 167 | font-style: normal; 168 | } 169 | label + input[type="range"] { 170 | margin-top: 4px; 171 | margin-bottom: 4px; 172 | } 173 | 174 | @font-face { 175 | font-family: 'Nunito'; 176 | src: url('fonts/Nunito-Medium.ttf') format('truetype'); 177 | font-weight: normal; 178 | font-style: normal; 179 | } 180 | 181 | @font-face { 182 | font-family: 'OpenDyslexic'; 183 | src: url('fonts/OpenDyslexic-Regular.otf') format('opentype'); 184 | font-weight: normal; 185 | font-style: normal; 186 | } 187 | 188 | @font-face { 189 | font-family: 'Roboto'; 190 | src: url('fonts/Roboto-Regular.ttf') format('truetype'); 191 | font-weight: normal; 192 | font-style: normal; 193 | } 194 | 195 | @font-face { 196 | font-family: 'SourGummy'; 197 | src: url('fonts/SourGummy-Regular.ttf') format('truetype'); 198 | font-weight: normal; 199 | font-style: normal; 200 | } 201 | 202 | @font-face { 203 | font-family: 'Nunito'; 204 | src: url('fonts/Nunito-Medium.ttf') format('truetype'); 205 | font-weight: normal; 206 | font-style: normal; 207 | } -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | // Elements 2 | const dyslexiaBtn = document.getElementById("dyslexia"); 3 | const colorBlindBtn = document.getElementById("colorblindMode"); 4 | const resetBtn = document.getElementById("reset"); 5 | const simplifyBtn = document.getElementById("simplify"); 6 | const translateBtn = document.getElementById("translate"); 7 | const speakBtn = document.getElementById("speak"); 8 | 9 | const fontSelect = document.getElementById("fontSelect"); 10 | const fontSizeSlider = document.getElementById("fontSizeSlider"); 11 | const fontSizeValue = document.getElementById("fontSizeValue"); 12 | 13 | const fontSpacingSlider = document.getElementById("fontSpacingSlider"); 14 | const fontSpacingValue = document.getElementById("fontSpacingValue"); 15 | 16 | const languageSelect = document.getElementById("languageSelect"); 17 | const toggleBoldBtn = document.getElementById("toggleBold"); 18 | 19 | 20 | const voiceSelect = document.getElementById("voiceSelect"); 21 | const speechRateSlider = document.getElementById("speechRateSlider"); 22 | const speechRateValue = document.getElementById("speechRateValue"); 23 | const speechPitchSlider = document.getElementById("speechPitchSlider"); 24 | const speechPitchValue = document.getElementById("speechPitchValue"); 25 | 26 | // --- Restore Stored Values on Load --- 27 | 28 | window.onload = () => { 29 | 30 | populateVoiceOptions(); 31 | 32 | chrome.storage.sync.get( 33 | [ 34 | "colorblindModeEnabled", 35 | "language", 36 | "speechVoice", 37 | "speechRate", 38 | "speechPitch", 39 | ], 40 | ({ 41 | colorblindModeEnabled, 42 | language, 43 | speechVoice, 44 | speechRate, 45 | speechPitch, 46 | }) => { 47 | // Set initial colorblind button text based on stored state 48 | if (colorblindModeEnabled) { 49 | colorBlindBtn.textContent = "disable colorblind mode"; 50 | } else { 51 | colorBlindBtn.textContent = "enable colorblind mode"; 52 | } 53 | 54 | // Set language if available 55 | if (language && languageSelect) { 56 | languageSelect.value = language; 57 | } 58 | 59 | // Set speech settings if available 60 | if (speechRate && speechRateSlider && speechRateValue) { 61 | speechRateSlider.value = speechRate; 62 | speechRateValue.textContent = speechRate.toFixed(1); 63 | } 64 | 65 | if (speechPitch && speechPitchSlider && speechPitchValue) { 66 | speechPitchSlider.value = speechPitch; 67 | speechPitchValue.textContent = speechPitch.toFixed(1); 68 | } 69 | 70 | if (speechVoice && voiceSelect) { 71 | 72 | setTimeout(() => { 73 | if (voiceSelect.querySelector(`option[value="${speechVoice}"]`)) { 74 | voiceSelect.value = speechVoice; 75 | } 76 | }, 100); 77 | } 78 | } 79 | ); 80 | 81 | 82 | fontSizeValue.textContent = fontSizeSlider.value + "px"; 83 | fontSpacingValue.textContent = fontSpacingSlider.value + "px"; 84 | 85 | 86 | chrome.storage.sync.get(["font", "size", "spacing", "isBold"], (data) => { 87 | if (data.font) { 88 | fontSelect.value = data.font; 89 | } 90 | if (data.size) { 91 | fontSizeSlider.value = data.size; 92 | fontSizeValue.textContent = data.size + "px"; 93 | } 94 | if (data.spacing) { 95 | fontSpacingSlider.value = data.spacing; 96 | fontSpacingValue.textContent = data.spacing + "px"; 97 | } 98 | if (data.isBold) { 99 | isBold = data.isBold; 100 | toggleBoldBtn.textContent = isBold ? "unbold" : "bold"; 101 | applyBoldState(); 102 | } 103 | if (data.font && data.size && data.spacing) { 104 | applyFontChanges(); 105 | } 106 | }); 107 | }; 108 | 109 | 110 | function populateVoiceOptions() { 111 | if (!voiceSelect) return; 112 | 113 | 114 | while (voiceSelect.options.length > 1) { 115 | voiceSelect.remove(1); 116 | } 117 | 118 | 119 | let voices = speechSynthesis.getVoices(); 120 | 121 | 122 | if (voices.length === 0) { 123 | speechSynthesis.onvoiceschanged = () => { 124 | voices = speechSynthesis.getVoices(); 125 | populateVoiceList(voices); 126 | }; 127 | } else { 128 | populateVoiceList(voices); 129 | } 130 | } 131 | 132 | function populateVoiceList(voices) { 133 | 134 | chrome.storage.sync.get(["speechVoice"], ({ speechVoice }) => { 135 | 136 | voices.forEach((voice) => { 137 | const option = document.createElement("option"); 138 | option.textContent = `${voice.name} (${voice.lang})`; 139 | option.value = voice.name; 140 | voiceSelect.appendChild(option); 141 | 142 | 143 | if (speechVoice && voice.name === speechVoice) { 144 | option.selected = true; 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | // --- Button Actions --- 151 | 152 | 153 | colorBlindBtn.addEventListener("click", () => { 154 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 155 | chrome.storage.sync.get(["colorblindModeEnabled"], (result) => { 156 | 157 | const currentState = result.colorblindModeEnabled || false; 158 | 159 | const newState = !currentState; 160 | 161 | // Update storage 162 | chrome.storage.sync.set({ colorblindModeEnabled: newState }, () => { 163 | // Send message to content script 164 | chrome.tabs.sendMessage( 165 | tabs[0].id, 166 | { 167 | action: "toggleColorblindMode", 168 | }, 169 | (response) => { 170 | // Update button text based on the new state 171 | if (response && response.colorblindModeEnabled !== undefined) { 172 | colorBlindBtn.textContent = response.colorblindModeEnabled 173 | ? "disable colorblind mode" 174 | : "enable colorblind mode"; 175 | } 176 | } 177 | ); 178 | }); 179 | }); 180 | }); 181 | }); 182 | 183 | // Speak selected text 184 | if (speakBtn) { 185 | speakBtn.addEventListener("click", () => { 186 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 187 | chrome.tabs.sendMessage(tabs[0].id, { 188 | action: "speakText", 189 | }); 190 | }); 191 | }); 192 | } 193 | 194 | // Simplify text 195 | if (simplifyBtn) { 196 | simplifyBtn.addEventListener("click", () => { 197 | sendPrompt( 198 | "IMPORTANT FORMATTING INSTRUCTIONS: You must ONLY output the simplified version of the text, with NO additional text, NO explanations, NO introductions like 'Here is the simplified text', and NO comments of any kind. Your entire response must contain ONLY the simplified text.\n\nSimplify this text making it easier to read and understand while preserving all meaning:\n\n{{text}}" 199 | ); 200 | }); 201 | } 202 | 203 | // Translate text 204 | if (translateBtn && languageSelect) { 205 | translateBtn.addEventListener("click", () => { 206 | const language = languageSelect.value; 207 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 208 | chrome.tabs.sendMessage(tabs[0].id, { 209 | action: "translatePage", 210 | language: language, 211 | }); 212 | }); 213 | }); 214 | 215 | 216 | languageSelect.addEventListener("change", () => { 217 | chrome.storage.sync.set({ language: languageSelect.value }); 218 | }); 219 | } 220 | 221 | // Voice settings 222 | if (voiceSelect) { 223 | voiceSelect.addEventListener("change", () => { 224 | chrome.storage.sync.set({ speechVoice: voiceSelect.value }); 225 | }); 226 | } 227 | 228 | if (speechRateSlider && speechRateValue) { 229 | speechRateSlider.addEventListener("input", () => { 230 | const rate = parseFloat(speechRateSlider.value); 231 | speechRateValue.textContent = rate.toFixed(1); 232 | chrome.storage.sync.set({ speechRate: rate }); 233 | }); 234 | } 235 | 236 | if (speechPitchSlider && speechPitchValue) { 237 | speechPitchSlider.addEventListener("input", () => { 238 | const pitch = parseFloat(speechPitchSlider.value); 239 | speechPitchValue.textContent = pitch.toFixed(1); 240 | chrome.storage.sync.set({ speechPitch: pitch }); 241 | }); 242 | } 243 | 244 | dyslexiaBtn.addEventListener("click", () => { 245 | fontSelect.value = "OpenDyslexic"; 246 | fontSizeSlider.value = 15; 247 | fontSizeValue.textContent = "15px"; 248 | fontSpacingSlider.value = 2.5; 249 | fontSpacingValue.textContent = "2.5px"; 250 | isBold = true; 251 | toggleBoldBtn.textContent = isBold ? "unbold" : "bold"; 252 | applyBoldState(); 253 | applyFontChanges(); 254 | }); 255 | 256 | let isBold = false; 257 | 258 | resetBtn.addEventListener("click", () => { 259 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 260 | chrome.tabs.sendMessage(tabs[0].id, { 261 | action: "resetToDefault", 262 | }); 263 | }); 264 | isBold = false; 265 | toggleBoldBtn.textContent = isBold ? "unbold" : "bold"; 266 | applyBoldState(); 267 | }); 268 | 269 | function applyBoldState() { 270 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 271 | chrome.tabs.sendMessage(tabs[0].id, { 272 | action: "toggleBold", 273 | bold: isBold, 274 | }); 275 | }); 276 | } 277 | 278 | toggleBoldBtn.addEventListener("click", () => { 279 | isBold = !isBold; 280 | 281 | chrome.storage.sync.set({ isBold }); 282 | 283 | applyBoldState(); 284 | 285 | toggleBoldBtn.textContent = isBold ? "unbold" : "bold"; 286 | }); 287 | 288 | // --- Font + Spacing Sliders --- 289 | 290 | fontSizeSlider.addEventListener("input", () => { 291 | fontSizeValue.textContent = fontSizeSlider.value + "px"; 292 | applyFontChanges(); 293 | }); 294 | 295 | fontSelect.addEventListener("change", applyFontChanges); 296 | 297 | fontSpacingSlider.addEventListener("input", () => { 298 | fontSpacingValue.textContent = fontSpacingSlider.value + "px"; 299 | applyFontChanges(); 300 | }); 301 | 302 | function applyFontChanges() { 303 | const font = fontSelect.value; 304 | const size = fontSizeSlider.value; 305 | const spacing = fontSpacingSlider.value; 306 | 307 | chrome.storage.sync.set({ font, size, spacing }); 308 | 309 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 310 | chrome.tabs.sendMessage(tabs[0].id, { 311 | action: "updateFont", 312 | font, 313 | size, 314 | spacing, 315 | }); 316 | }); 317 | } 318 | 319 | // --- Auto Mode Toggle --- 320 | 321 | // autoModeCheckbox.addEventListener("change", (e) => { 322 | // chrome.storage.sync.set({ autoMode: e.target.checked }); 323 | // }); 324 | 325 | // --- Send Prompt Helper --- 326 | 327 | function sendPrompt(promptText) { 328 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 329 | chrome.tabs.sendMessage(tabs[0].id, { 330 | action: "modifyPageText", 331 | prompt: promptText, 332 | }); 333 | }); 334 | } 335 | 336 | // --- Restore Stored Values on Load --- 337 | 338 | window.onload = () => { 339 | 340 | languageSelect.addEventListener("change", () => { 341 | chrome.storage.sync.set({ language: languageSelect.value }); 342 | }); 343 | 344 | 345 | fontSizeValue.textContent = fontSizeSlider.value + "px"; 346 | fontSpacingValue.textContent = fontSpacingSlider.value + "px"; 347 | 348 | 349 | chrome.storage.sync.get(["font", "size", "spacing"], (data) => { 350 | if (data.font) { 351 | fontSelect.value = data.font; // Set the selected font 352 | } 353 | if (data.size) { 354 | fontSizeSlider.value = data.size; // Set the font size slider 355 | fontSizeValue.textContent = data.size + "px"; // Update the font size display 356 | } 357 | if (data.spacing) { 358 | fontSpacingSlider.value = data.spacing; 359 | fontSpacingValue.textContent = data.spacing + "px"; 360 | } 361 | }); 362 | }; 363 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // Inject fonts 2 | const style = document.createElement("style"); 3 | style.textContent = ` 4 | @font-face { 5 | font-family: 'Boldonse'; 6 | src: url('${chrome.runtime.getURL("fonts/Boldonse-Regular.ttf")}') format('truetype'); 7 | font-weight: normal; 8 | font-style: normal; 9 | } 10 | @font-face { 11 | font-family: 'ComicSans'; 12 | src: url('${chrome.runtime.getURL("fonts/ComicSansMS.ttf")}') format('truetype'); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | @font-face { 17 | font-family: 'Nunito'; 18 | src: url('${chrome.runtime.getURL( 19 | "fonts/Nunito-Medium.ttf" 20 | )}') format('truetype'); 21 | font-weight: normal; 22 | font-style: normal; 23 | } 24 | @font-face { 25 | font-family: 'OpenDyslexic'; 26 | src: url('${chrome.runtime.getURL( 27 | "fonts/OpenDyslexic-Regular.otf" 28 | )}') format('opentype'); 29 | font-weight: normal; 30 | font-style: normal; 31 | } 32 | @font-face { 33 | font-family: 'Roboto'; 34 | src: url('${chrome.runtime.getURL( 35 | "fonts/Roboto-Regular.ttf" 36 | )}') format('truetype'); 37 | font-weight: normal; 38 | font-style: normal; 39 | } 40 | @font-face { 41 | font-family: 'SourGummy'; 42 | src: url('${chrome.runtime.getURL("fonts/SourGummy-Regular.ttf")}') format('truetype'); 43 | font-weight: normal; 44 | font-style: normal; 45 | } 46 | `; 47 | document.head.appendChild(style); 48 | 49 | 50 | let speechSynthesisActive = false; 51 | 52 | let originalStyles = {}; 53 | run_once = false; 54 | let colorblindModeEnabled = false; 55 | 56 | 57 | function saveSelection() { 58 | if (window.getSelection) { 59 | const sel = window.getSelection(); 60 | if (sel.getRangeAt && sel.rangeCount) { 61 | return sel.getRangeAt(0); 62 | } 63 | } 64 | return null; 65 | } 66 | 67 | 68 | function restoreSelection(range) { 69 | if (range) { 70 | if (window.getSelection) { 71 | const sel = window.getSelection(); 72 | sel.removeAllRanges(); 73 | sel.addRange(range); 74 | } 75 | } 76 | } 77 | 78 | 79 | function speakText(text) { 80 | 81 | stopSpeech(); 82 | 83 | 84 | const utterance = new SpeechSynthesisUtterance(text); 85 | 86 | 87 | chrome.storage.sync.get( 88 | ["speechVoice", "speechRate", "speechPitch"], 89 | (data) => { 90 | // Set default values if not found 91 | let voiceName = data.speechVoice || ""; 92 | let rate = data.speechRate || 1; 93 | let pitch = data.speechPitch || 1; 94 | 95 | 96 | if (voiceName) { 97 | const voices = window.speechSynthesis.getVoices(); 98 | const voice = voices.find((v) => v.name === voiceName); 99 | if (voice) { 100 | utterance.voice = voice; 101 | } 102 | } 103 | 104 | 105 | utterance.rate = rate; 106 | utterance.pitch = pitch; 107 | 108 | 109 | utterance.onstart = () => { 110 | speechSynthesisActive = true; 111 | console.log("Speech started"); 112 | }; 113 | 114 | utterance.onend = () => { 115 | speechSynthesisActive = false; 116 | console.log("Speech ended"); 117 | }; 118 | 119 | utterance.onerror = (event) => { 120 | speechSynthesisActive = false; 121 | console.error("Speech error:", event); 122 | }; 123 | 124 | // Speak the text 125 | window.speechSynthesis.speak(utterance); 126 | } 127 | ); 128 | } 129 | 130 | // Stop ongoing speech 131 | function stopSpeech() { 132 | if (speechSynthesisActive) { 133 | window.speechSynthesis.cancel(); 134 | speechSynthesisActive = false; 135 | } 136 | } 137 | 138 | function captureOriginalStyles() { 139 | originalStyles.body = { 140 | fontFamily: document.body.style.fontFamily, 141 | fontSize: document.body.style.fontSize, 142 | fontSpacing: document.body.style.letterSpacing, 143 | fontWeight: document.body.style.fontWeight, 144 | filter: document.body.style.filter, 145 | }; 146 | 147 | // Capture the original font family and size of all elements 148 | originalStyles.elements = []; 149 | const allElements = document.querySelectorAll("*:not(script):not(style)"); 150 | allElements.forEach((el) => { 151 | const style = window.getComputedStyle(el); 152 | const isVisible = style.display !== "none" && style.visibility !== "hidden"; 153 | if (isVisible) { 154 | originalStyles.elements.push({ 155 | element: el, 156 | fontFamily: style.fontFamily, 157 | fontSize: style.fontSize, 158 | fontSpacing: style.letterSpacing, 159 | fontWeight: style.fontWeight, 160 | }); 161 | } 162 | }); 163 | 164 | 165 | originalStyles.html = document.documentElement.innerHTML; 166 | } 167 | 168 | if (run_once == false) { 169 | captureOriginalStyles(); 170 | run_once = true; 171 | } 172 | 173 | 174 | function applyColorblindMode(enabled) { 175 | colorblindModeEnabled = enabled; 176 | console.log("Colorblind mode:", enabled ? "enabled" : "disabled"); 177 | 178 | if (enabled) { 179 | 180 | document.body.style.filter = "contrast(105%) saturate(200%)"; 181 | } else { 182 | 183 | document.body.style.filter = "none"; 184 | } 185 | } 186 | 187 | 188 | async function callCohere(prompt) { 189 | try { 190 | // Show loading indicator 191 | const loadingSpan = document.createElement('span'); 192 | loadingSpan.textContent = '⌛ Processing...'; 193 | loadingSpan.style.backgroundColor = '#fff3cd'; 194 | loadingSpan.style.padding = '2px 5px'; 195 | loadingSpan.style.borderRadius = '3px'; 196 | document.body.appendChild(loadingSpan); 197 | 198 | const res = await fetch("https://textsavvy-backend.onrender.com/api/modify", { 199 | method: "POST", 200 | headers: { 201 | "Content-Type": "application/json", 202 | "Accept": "application/json" 203 | }, 204 | mode: "cors", 205 | credentials: "omit", 206 | body: JSON.stringify({ 207 | text: prompt, 208 | prompt: prompt 209 | }), 210 | }); 211 | 212 | // Remove loading indicator 213 | loadingSpan.remove(); 214 | 215 | if (!res.ok) { 216 | throw new Error(`HTTP error! status: ${res.status}`); 217 | } 218 | const data = await res.json(); 219 | return { text: data.text }; 220 | } catch (error) { 221 | console.error("API error:", error); 222 | // Show error message to user 223 | const errorSpan = document.createElement('span'); 224 | errorSpan.textContent = '❌ Error: Could not process text. Please try again.'; 225 | errorSpan.style.backgroundColor = '#f8d7da'; 226 | errorSpan.style.padding = '2px 5px'; 227 | errorSpan.style.borderRadius = '3px'; 228 | document.body.appendChild(errorSpan); 229 | setTimeout(() => errorSpan.remove(), 3000); 230 | return { error: error.message }; 231 | } 232 | } 233 | 234 | 235 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 236 | console.table(request); 237 | 238 | if (request.action === "modifyPageText") { 239 | const selection = window.getSelection().toString(); 240 | if (!selection) return; 241 | 242 | const savedRange = saveSelection(); 243 | const selectedText = selection; 244 | 245 | const prompt = request.prompt.replace("{{text}}", selectedText); 246 | callCohere(prompt).then((response) => { 247 | if (!response || !response.text) return; 248 | 249 | restoreSelection(savedRange); 250 | 251 | const newText = `${response.text}`; 252 | const range = window.getSelection().getRangeAt(0); 253 | range.deleteContents(); 254 | const temp = document.createElement("div"); 255 | temp.innerHTML = newText; 256 | range.insertNode(temp.firstChild); 257 | }); 258 | } 259 | 260 | if (request.action === "updateFont") { 261 | applyFontToAll(request.font, request.size, request.spacing); 262 | } 263 | 264 | if (request.action === "speakText") { 265 | const selection = window.getSelection().toString().trim(); 266 | if (selection) { 267 | speakText(selection); 268 | } 269 | } 270 | 271 | function applyFontToAll(font, size, spacing) { 272 | document.body.style.fontFamily = font; 273 | document.body.style.fontSize = size + "px"; 274 | console.log("Font: " + document.body.style.fontFamily); 275 | 276 | document.body.style.letterSpacing = spacing + "px"; 277 | 278 | const allElements = document.querySelectorAll("*:not(script):not(style)"); 279 | 280 | allElements.forEach((el) => { 281 | const style = window.getComputedStyle(el); 282 | const isVisible = 283 | style.display !== "none" && style.visibility !== "hidden"; 284 | if (isVisible) { 285 | el.style.fontFamily = font; 286 | el.style.fontSize = size + "px"; 287 | el.style.letterSpacing = spacing + "px"; 288 | } 289 | }); 290 | } 291 | 292 | if (request.action === "resetToDefault") { 293 | 294 | stopSpeech(); 295 | 296 | resetToOriginalStyles(); 297 | } 298 | 299 | function resetToOriginalStyles() { 300 | // Restore the original font family and size of the body 301 | document.body.style.fontFamily = originalStyles.body.fontFamily; 302 | document.body.style.fontSize = originalStyles.body.fontSize; 303 | document.body.style.letterSpacing = originalStyles.body.fontSpacing; 304 | document.body.style.fontWeight = originalStyles.body.fontWeight; 305 | document.body.style.filter = originalStyles.body.filter; 306 | 307 | // Restore the original font family and size of all elements 308 | originalStyles.elements.forEach((item) => { 309 | try { 310 | if (item.element && item.element.style) { 311 | item.element.style.fontFamily = item.fontFamily; 312 | item.element.style.fontSize = item.fontSize; 313 | item.element.style.letterSpacing = item.fontSpacing; 314 | item.element.style.fontWeight = item.fontWeight; 315 | } 316 | } catch (e) { 317 | console.log("Error resetting element:", e); 318 | } 319 | }); 320 | 321 | 322 | const allSpans = document.querySelectorAll("span[style*='background']"); 323 | allSpans.forEach((span) => { 324 | span.outerHTML = span.innerHTML; 325 | }); 326 | 327 | 328 | colorblindModeEnabled = false; 329 | document.body.style.filter = "none"; 330 | } 331 | 332 | if (request.action === "toggleBold") { 333 | toggleBoldAll(request.bold); 334 | } 335 | 336 | function toggleBoldAll(applyBold) { 337 | const allElements = document.querySelectorAll("*:not(script):not(style)"); 338 | 339 | allElements.forEach((el) => { 340 | const style = window.getComputedStyle(el); 341 | const isVisible = 342 | style.display !== "none" && style.visibility !== "hidden"; 343 | if (isVisible) { 344 | el.style.fontWeight = applyBold ? "bold" : "normal"; 345 | } 346 | }); 347 | } 348 | 349 | if (request.action === "translatePage") { 350 | handleTranslatePage(request.language); 351 | } 352 | 353 | if (request.action === "toggleColorblindMode") { 354 | applyColorblindMode(!colorblindModeEnabled); 355 | 356 | sendResponse({ colorblindModeEnabled: colorblindModeEnabled }); 357 | return true; 358 | } 359 | }); 360 | 361 | async function handleTranslatePage(language) { 362 | const selection = window.getSelection().toString().trim(); 363 | if (!selection) return; 364 | 365 | const savedRange = saveSelection(); 366 | const selectedText = selection; 367 | 368 | try { 369 | // Show loading indicator 370 | const loadingSpan = document.createElement('span'); 371 | loadingSpan.textContent = `⌛ loading ...`; 372 | loadingSpan.style.backgroundColor = '#fff3cd'; 373 | loadingSpan.style.padding = '2px 5px'; 374 | loadingSpan.style.borderRadius = '3px'; 375 | document.body.appendChild(loadingSpan); 376 | 377 | const res = await fetch("https://textsavvy-backend.onrender.com/api/translate", { 378 | method: "POST", 379 | headers: { 380 | "Content-Type": "application/json", 381 | "Accept": "application/json" 382 | }, 383 | mode: "cors", 384 | credentials: "omit", 385 | body: JSON.stringify({ 386 | text: selectedText, 387 | language: language 388 | }), 389 | }); 390 | 391 | // Remove loading indicator 392 | loadingSpan.remove(); 393 | 394 | if (!res.ok) { 395 | throw new Error(`HTTP error! status: ${res.status}`); 396 | } 397 | const data = await res.json(); 398 | 399 | if (!data || !data.text) { 400 | throw new Error('No translation received'); 401 | } 402 | 403 | restoreSelection(savedRange); 404 | const newText = `${data.text}`; 405 | const range = window.getSelection().getRangeAt(0); 406 | range.deleteContents(); 407 | const temp = document.createElement("div"); 408 | temp.innerHTML = newText; 409 | range.insertNode(temp.firstChild); 410 | } catch (error) { 411 | console.error("Translation error:", error); 412 | // Show error message to user 413 | const errorSpan = document.createElement('span'); 414 | errorSpan.textContent = '❌ Translation failed. Please try again.'; 415 | errorSpan.style.backgroundColor = '#f8d7da'; 416 | errorSpan.style.padding = '2px 5px'; 417 | errorSpan.style.borderRadius = '3px'; 418 | document.body.appendChild(errorSpan); 419 | setTimeout(() => errorSpan.remove(), 3000); 420 | } 421 | } 422 | --------------------------------------------------------------------------------