├── icons ├── icon128.png ├── icon16.png ├── icon48.png ├── icon16-light.png ├── icon48-light.png ├── icon128-light.png ├── icon.svg └── icon-dark.svg ├── .gitignore ├── config.js ├── background.js ├── LICENSE ├── README.md ├── manifest.json ├── content.js ├── types.d.ts ├── popup ├── popup.html ├── popup.css └── popup.js └── interceptor.js /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon48.png -------------------------------------------------------------------------------- /icons/icon16-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon16-light.png -------------------------------------------------------------------------------- /icons/icon48-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon48-light.png -------------------------------------------------------------------------------- /icons/icon128-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unclecode/claude-paster/HEAD/icons/icon128-light.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | tmp/ 40 | todo.md -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | // config.js 2 | const DEFAULT_SETTINGS = { 3 | lengthThreshold: 3999, 4 | openDelimiter: '', 5 | closeDelimiter: '', 6 | useDelimiters: true, 7 | escapeDelimiters: false, 8 | forceDelimiters: false, 9 | multilineOnly: true, 10 | autoRecover: true, 11 | recoveryInterval: 1000, 12 | enabled: true, 13 | allowDefault: false, 14 | interceptStandardPaste: false 15 | }; 16 | 17 | // Listen for requests for default settings 18 | window.addEventListener('message', function(event) { 19 | if (event.data.type === 'REQUEST_DEFAULT_SETTINGS') { 20 | window.postMessage({ 21 | type: 'DEFAULT_SETTINGS_RESPONSE', 22 | settings: DEFAULT_SETTINGS 23 | }, '*'); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | chrome.runtime.onInstalled.addListener(() => { 2 | // Initialize default settings if they don't exist 3 | const DEFAULT_SETTINGS = { 4 | lengthThreshold: 3999, 5 | openDelimiter: "", 6 | closeDelimiter: "", 7 | useDelimiters: true, 8 | escapeDelimiters: false, 9 | forceDelimiters: false, 10 | multilineOnly: true, 11 | autoRecover: true, 12 | recoveryInterval: 1000, 13 | enabled: true, 14 | allowDefault: false, 15 | interceptStandardPaste: false, 16 | }; 17 | 18 | chrome.storage.sync.get(DEFAULT_SETTINGS, (settings) => { 19 | chrome.storage.sync.set(settings, () => { 20 | console.log("Settings initialized:", settings); 21 | }); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | paste 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /icons/icon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | paste 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Unclecode 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ClaudePaster 2 | 3 | Seeing lots of folks annoyed when Claude turns their long pastes into attachments? Yeah, me too. That's why I built ClaudePaster - a simple Chrome extension that lets you paste lengthy content without it converting to attachments automatically. 4 | 5 |  6 | 7 | ## What it does 8 | - Prevents Claude from auto-converting long text pastes into attachments 9 | - Gives you full control over your text input 10 | - Simple toggle in settings to enable/disable when needed 11 | 12 | ## Like this solution? 13 | Found this helpful? Consider giving it a ⭐ star on GitHub! It helps others find the solution and motivates me to keep improving it. 14 | 15 | ## Installation 16 | 1. Download this repository as ZIP (or clone it) 17 | 2. Open Chrome and navigate to `chrome://extensions/` 18 | 3. Enable "Developer mode" in the top-right corner 19 | 4. Click "Load unpacked" button 20 | 5. Select the downloaded (and unzipped) extension folder 21 | 22 | ## How to use 23 | Once installed, just paste your content as usual in Claude - no more automatic attachments! If you ever need the original behavior, you can disable the extension through its settings. 24 | 25 | ## Support 26 | Found a bug or have a suggestion? Feel free to open an issue or contribute! 27 | 28 | ## License 29 | MIT -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Claude Paster", 4 | "version": "1.0.0", 5 | "description": "Tired of long text inputs automatically converting to attachments in Claude AI? ClaudePaster lets you paste lengthy content while maintaining full text control and editability.", 6 | "permissions": ["activeTab", "clipboardRead", "storage"], 7 | "host_permissions": ["https://claude.ai/*"], 8 | "action": { 9 | "default_popup": "popup/popup.html", 10 | "default_icon": { 11 | "16": "icons/icon16.png", 12 | "48": "icons/icon48.png", 13 | "128": "icons/icon128.png" 14 | }, 15 | "theme_icons": [{ 16 | "light": "icons/icon16-light.png", 17 | "dark": "icons/icon16.png", 18 | "size": 16 19 | }, { 20 | "light": "icons/icon48-light.png", 21 | "dark": "icons/icon48.png", 22 | "size": 48 23 | }, { 24 | "light": "icons/icon128-light.png", 25 | "dark": "icons/icon128.png", 26 | "size": 128 27 | }] 28 | }, 29 | "icons": { 30 | "16": "icons/icon16.png", 31 | "48": "icons/icon48.png", 32 | "128": "icons/icon128.png" 33 | }, 34 | 35 | "web_accessible_resources": [ 36 | { 37 | "resources": ["config.js", "interceptor.js" ], 38 | "matches": ["https://claude.ai/*"] 39 | } 40 | ], 41 | "content_scripts": [ 42 | { 43 | "matches": ["https://claude.ai/*"], 44 | "js": ["content.js"], 45 | "run_at": "document_end" 46 | } 47 | ], 48 | "background": { 49 | "service_worker": "background.js" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // // Define default settings (should match the ones in popup.js) 2 | // const DEFAULT_SETTINGS = { 3 | // lengthThreshold: 5000, 4 | // openDelimiter: '```', 5 | // closeDelimiter: '```', 6 | // useDelimiters: true, 7 | // escapeDelimiters: true, 8 | // forceDelimiters: false, 9 | // multilineOnly: true, 10 | // autoRecover: true, 11 | // recoveryInterval: 1000 12 | // }; 13 | 14 | async function injectInterceptor() { 15 | try { 16 | // First inject config.js 17 | const configUrl = chrome.runtime.getURL('config.js'); 18 | await injectScript(configUrl); 19 | 20 | // Then inject interceptor.js 21 | const interceptorUrl = chrome.runtime.getURL('interceptor.js'); 22 | await injectScript(interceptorUrl); 23 | 24 | // Get settings from chrome storage 25 | chrome.storage.sync.get(null, (settings) => { 26 | // If settings exist in storage, use them 27 | if (Object.keys(settings).length > 0) { 28 | window.postMessage({ 29 | type: 'INTERCEPTOR_SETTINGS', 30 | settings: settings 31 | }, '*'); 32 | } else { 33 | // If no settings exist, request defaults from the page context 34 | window.postMessage({ 35 | type: 'REQUEST_DEFAULT_SETTINGS' 36 | }, '*'); 37 | } 38 | }); 39 | } catch (error) { 40 | console.error('Failed to inject scripts:', error); 41 | } 42 | } 43 | 44 | function injectScript(url) { 45 | return new Promise((resolve, reject) => { 46 | const script = document.createElement('script'); 47 | script.src = url; 48 | script.type = 'text/javascript'; 49 | script.onload = resolve; 50 | script.onerror = reject; 51 | (document.head || document.documentElement).appendChild(script); 52 | }); 53 | } 54 | 55 | // Listen for messages from the page context 56 | window.addEventListener('message', function(event) { 57 | if (event.data.type === 'DEFAULT_SETTINGS_RESPONSE') { 58 | // Store the default settings 59 | chrome.storage.sync.set(event.data.settings, () => { 60 | // Then initialize the interceptor with these settings 61 | window.postMessage({ 62 | type: 'INTERCEPTOR_SETTINGS', 63 | settings: event.data.settings 64 | }, '*'); 65 | }); 66 | } 67 | }); 68 | 69 | // Initialize when the page is ready 70 | if (document.readyState === 'loading') { 71 | document.addEventListener('DOMContentLoaded', () => { 72 | setTimeout(injectInterceptor, 1000); 73 | }); 74 | } else { 75 | setTimeout(injectInterceptor, 1000); 76 | } 77 | 78 | // Handle settings updates 79 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 80 | if (message.type === 'settingsUpdated') { 81 | // Use postMessage instead of inline script 82 | window.postMessage({ 83 | type: 'INTERCEPTOR_SETTINGS', 84 | settings: message.settings 85 | }, '*'); 86 | } 87 | }); -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | // types.d.ts 2 | 3 | // Main interceptor options interface 4 | interface InterceptorOptions { 5 | lengthThreshold: number; 6 | openDelimiter: string; 7 | closeDelimiter: string | null; 8 | useDelimiters: boolean; 9 | escapeDelimiters: boolean; 10 | forceDelimiters: boolean; 11 | multilineOnly: boolean; 12 | autoRecover: boolean; 13 | recoveryInterval: number; 14 | debugMode?: boolean; 15 | } 16 | 17 | // Editor interface (for ProseMirror integration) 18 | interface EditorView { 19 | state: EditorState; 20 | dispatch: (tr: Transaction) => void; 21 | } 22 | 23 | interface EditorState { 24 | schema: Schema; 25 | tr: Transaction; 26 | selection: Selection; 27 | } 28 | 29 | interface Schema { 30 | text: (content: string) => Text; 31 | } 32 | 33 | interface Selection { 34 | from: number; 35 | to: number; 36 | } 37 | 38 | interface Transaction { 39 | insertText: (text: string, pos: number) => Transaction; 40 | insert: (pos: number, content: any) => Transaction; 41 | } 42 | 43 | interface Text { 44 | type: string; 45 | text: string; 46 | } 47 | 48 | // Main ContentInterceptor class interface 49 | interface ContentInterceptor { 50 | editorSelector: string; 51 | editor: HTMLElement | null; 52 | events: Set; 53 | options: InterceptorOptions; 54 | recoveryTimer: number | null; 55 | _pasteHandler: ((e: KeyboardEvent) => void) | null; 56 | 57 | // Core methods 58 | init(): void; 59 | destroy(stopMonitoring?: boolean): void; 60 | waitForEditor(): Promise; 61 | 62 | // Event handling 63 | setupEventListeners(): void; 64 | handleCustomPaste(): Promise; 65 | handleSmallPaste(content: string): void; 66 | handleLargePaste(content: string): void; 67 | 68 | // Content processing 69 | processSmallContent(content: string): string; 70 | processLargeContent(content: string): string; 71 | detectIfCode(content: string): boolean; 72 | escapeSpecialChars(text: string): string; 73 | wrapWithDelimiters(content: string): string; 74 | shouldUseDelimiters(content: string): boolean; 75 | 76 | // Content insertion methods 77 | insertContentUsingProseMirror(content: string): boolean; 78 | insertContentAltMethod(content: string): boolean; 79 | insertContentFallback(content: string): boolean; 80 | 81 | // Editor recovery methods 82 | startEditorMonitoring(): void; 83 | stopEditorMonitoring(): void; 84 | handleEditorLoss(): void; 85 | 86 | // Debug logging 87 | debugLog(message: string, data?: any): void; 88 | } 89 | 90 | // Storage related types 91 | interface StorageChange { 92 | oldValue?: any; 93 | newValue?: any; 94 | } 95 | 96 | interface StorageChanges { 97 | [key: string]: StorageChange; 98 | } 99 | 100 | // Message types for communication between components 101 | type MessageType = 'settingsUpdated' | 'debugLog' | 'editorStatus'; 102 | 103 | interface Message { 104 | type: MessageType; 105 | settings?: InterceptorOptions; 106 | debugInfo?: { 107 | message: string; 108 | data?: any; 109 | }; 110 | editorStatus?: { 111 | found: boolean; 112 | selector: string; 113 | }; 114 | } 115 | 116 | // Chrome extension specific types 117 | interface ChromeStorageSync { 118 | get(keys: string | string[] | Object | null): Promise; 119 | set(items: Object): Promise; 120 | remove(keys: string | string[]): Promise; 121 | clear(): Promise; 122 | } 123 | 124 | interface ChromeRuntime { 125 | onMessage: { 126 | addListener: ( 127 | callback: ( 128 | message: Message, 129 | sender: chrome.runtime.MessageSender, 130 | sendResponse: (response?: any) => void 131 | ) => void | boolean 132 | ) => void; 133 | }; 134 | sendMessage: (message: Message) => Promise; 135 | } 136 | 137 | // Declare global window properties 138 | declare global { 139 | interface Window { 140 | _interceptor: ContentInterceptor; 141 | chrome: { 142 | storage: { 143 | sync: ChromeStorageSync; 144 | }; 145 | runtime: ChromeRuntime; 146 | }; 147 | } 148 | } 149 | 150 | // Export to make this a module 151 | export {}; -------------------------------------------------------------------------------- /popup/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Claude Paste Helper 6 | 7 | 8 | 9 | 10 | Claude Paste Helper 11 | Saved 12 | 13 | 14 | 15 | 16 | Global Settings 17 | 18 | Enable Extension 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Intercept paste (Cmd/Ctrl+V) 30 | 31 | 32 | 33 | 34 | 35 | When disabled, only custom shortcut (Cmd/Ctrl + Alt/Shift + V) will trigger the interceptorg 36 | 37 | 38 | Allow Default Paste 39 | 40 | 41 | 42 | 43 | 44 | Let the browser handle paste after our processing 45 | 46 | 47 | 48 | 49 | Delimiter Settings 50 | 51 | Use Delimiters 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Open Delimiter 60 | 61 | 62 | 63 | 64 | 65 | Close Delimiter 66 | 67 | 68 | 69 | 70 | 71 | Escape Delimiters 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Behavior Settings 82 | 83 | Force Delimiters 84 | 85 | 86 | 87 | 88 | 89 | Always use delimiters regardless of content 90 | 91 | 92 | Multiline Only 93 | 94 | 95 | 96 | 97 | 98 | Only use delimiters for multiline content 99 | 100 | 101 | Length Threshold 102 | 103 | 104 | 105 | Characters before special handling 106 | 107 | 108 | 109 | 110 | Recovery Settings 111 | 112 | Auto Recover 113 | 114 | 115 | 116 | 117 | 118 | Attempt auto-recovery of lost editor 119 | 120 | 121 | Recovery Interval 122 | 123 | 124 | 125 | Check interval in milliseconds 126 | 127 | 128 | 129 | 130 | Reset 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /popup/popup.css: -------------------------------------------------------------------------------- 1 | /* popup/popup.css */ 2 | 3 | :root { 4 | --bg-primary: #1a1a1a; 5 | --bg-secondary: #252525; 6 | --text-primary: #ffffff; 7 | --text-secondary: #a0a0a0; 8 | --border-color: #333333; 9 | --accent-color: #4f46e5; 10 | --accent-hover: #6366f1; 11 | --danger-color: #dc2626; 12 | --input-bg: #2d2d2d; 13 | --switch-bg: #404040; 14 | } 15 | 16 | html { 17 | width: 450px; 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | body { 23 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; 24 | font-size: 14px; 25 | line-height: 1.6; 26 | color: var(--text-primary); 27 | background: var(--bg-primary); 28 | margin: 0; 29 | padding: 20px; 30 | width: 100%; 31 | box-sizing: border-box; 32 | min-width: 450px; 33 | } 34 | 35 | /* Settings groups */ 36 | .settings-group { 37 | background: var(--bg-secondary); 38 | border: 1px solid var(--border-color); 39 | border-radius: 8px; 40 | padding: 16px; 41 | margin-bottom: 16px; 42 | } 43 | 44 | .settings-group h2 { 45 | font-size: 15px; 46 | font-weight: 600; 47 | color: var(--text-primary); 48 | margin: 0 0 16px 0; 49 | padding-bottom: 8px; 50 | border-bottom: 1px solid var(--border-color); 51 | } 52 | 53 | /* Individual settings */ 54 | .setting { 55 | margin-bottom: 16px; 56 | display: grid; 57 | grid-template-columns: 140px 1fr auto; 58 | align-items: center; 59 | gap: 12px; 60 | } 61 | 62 | .setting:last-child { 63 | margin-bottom: 0; 64 | } 65 | 66 | .setting-label { 67 | color: var(--text-primary); 68 | font-weight: 500; 69 | white-space: nowrap; 70 | } 71 | 72 | .setting-control { 73 | position: relative; 74 | } 75 | 76 | .setting-help { 77 | color: var(--text-secondary); 78 | font-size: 12px; 79 | grid-column: 2 / -1; 80 | margin-top: -12px; 81 | margin-bottom: 8px; 82 | } 83 | 84 | /* Input styling */ 85 | .text-input, .number-input { 86 | width: 100%; 87 | padding: 8px 12px; 88 | background: var(--input-bg); 89 | border: 1px solid var(--border-color); 90 | border-radius: 6px; 91 | color: var(--text-primary); 92 | font-size: 13px; 93 | transition: border-color 0.2s; 94 | box-sizing: border-box; 95 | } 96 | 97 | .text-input:focus, .number-input:focus { 98 | border-color: var(--accent-color); 99 | outline: none; 100 | } 101 | 102 | /* Checkbox styling */ 103 | .checkbox-wrapper { 104 | display: inline-flex; 105 | align-items: center; 106 | } 107 | 108 | input[type="checkbox"] { 109 | appearance: none; 110 | -webkit-appearance: none; 111 | width: 18px; 112 | height: 18px; 113 | border: 2px solid var(--border-color); 114 | border-radius: 4px; 115 | background: var(--input-bg); 116 | cursor: pointer; 117 | position: relative; 118 | margin: 0; 119 | padding: 0; 120 | } 121 | 122 | input[type="checkbox"]:checked { 123 | background: var(--accent-color); 124 | border-color: var(--accent-color); 125 | } 126 | 127 | input[type="checkbox"]:checked::before { 128 | content: ""; 129 | position: absolute; 130 | left: 5px; 131 | top: 2px; 132 | width: 4px; 133 | height: 8px; 134 | border: solid white; 135 | border-width: 0 2px 2px 0; 136 | transform: rotate(45deg); 137 | } 138 | 139 | /* Toggle switch */ 140 | .toggle-wrapper { 141 | display: flex; 142 | align-items: center; 143 | justify-content: flex-end; 144 | } 145 | 146 | .toggle-switch { 147 | position: relative; 148 | display: inline-block; 149 | width: 46px; 150 | height: 24px; 151 | margin: 0; 152 | } 153 | 154 | .toggle-switch input { 155 | opacity: 0; 156 | width: 0; 157 | height: 0; 158 | margin: 0; 159 | } 160 | 161 | .slider { 162 | position: absolute; 163 | cursor: pointer; 164 | top: 0; 165 | left: 0; 166 | right: 0; 167 | bottom: 0; 168 | background-color: var(--switch-bg); 169 | transition: .3s; 170 | border-radius: 24px; 171 | } 172 | 173 | .slider:before { 174 | position: absolute; 175 | content: ""; 176 | height: 18px; 177 | width: 18px; 178 | left: 3px; 179 | bottom: 3px; 180 | background-color: white; 181 | transition: .3s; 182 | border-radius: 50%; 183 | } 184 | 185 | input:checked + .slider { 186 | background-color: var(--accent-color); 187 | } 188 | 189 | input:checked + .slider:before { 190 | transform: translateX(22px); 191 | } 192 | 193 | /* Header */ 194 | h1 { 195 | font-size: 20px; 196 | font-weight: 600; 197 | margin: 0 0 20px 0; 198 | color: var(--text-primary); 199 | } 200 | 201 | /* Action buttons */ 202 | .settings-actions { 203 | display: flex; 204 | gap: 12px; 205 | margin-top: 20px; 206 | } 207 | 208 | .primary-button, .secondary-button { 209 | padding: 10px 16px; 210 | border-radius: 6px; 211 | border: none; 212 | cursor: pointer; 213 | font-size: 14px; 214 | font-weight: 500; 215 | transition: all 0.2s; 216 | } 217 | 218 | .primary-button { 219 | background: var(--accent-color); 220 | color: white; 221 | flex: 1; 222 | } 223 | 224 | .primary-button:hover { 225 | background: var(--accent-hover); 226 | } 227 | 228 | .secondary-button { 229 | background: var(--bg-secondary); 230 | color: var(--text-primary); 231 | border: 1px solid var(--border-color); 232 | } 233 | 234 | .secondary-button:hover { 235 | background: var(--input-bg); 236 | } 237 | 238 | .save-indicator { 239 | opacity: 0; 240 | transition: opacity 0.3s ease-in-out; 241 | color: #4CAF50; 242 | font-size: 0.9em; 243 | margin-right: 10px; 244 | } -------------------------------------------------------------------------------- /popup/popup.js: -------------------------------------------------------------------------------- 1 | // popup.js 2 | 3 | // First, inject config.js to get access to DEFAULT_SETTINGS 4 | function injectConfigScript() { 5 | return new Promise((resolve, reject) => { 6 | const configUrl = chrome.runtime.getURL('config.js'); 7 | const script = document.createElement('script'); 8 | script.src = configUrl; 9 | script.onload = () => { 10 | // Listen for the response containing default settings 11 | window.addEventListener('message', function onMessage(event) { 12 | if (event.data.type === 'DEFAULT_SETTINGS_RESPONSE') { 13 | window.removeEventListener('message', onMessage); 14 | resolve(event.data.settings); 15 | } 16 | }); 17 | // Request default settings 18 | window.postMessage({ type: 'REQUEST_DEFAULT_SETTINGS' }, '*'); 19 | }; 20 | script.onerror = reject; 21 | document.head.appendChild(script); 22 | }); 23 | } 24 | 25 | // Load settings from storage or initialize with defaults 26 | async function loadSettings() { 27 | try { 28 | // Get settings from storage 29 | chrome.storage.sync.get(null, async (settings) => { 30 | let finalSettings; 31 | 32 | // If storage is empty, get defaults from config.js 33 | if (Object.keys(settings).length === 0) { 34 | const defaults = await injectConfigScript(); 35 | finalSettings = defaults; 36 | // Save defaults to storage 37 | chrome.storage.sync.set(defaults); 38 | } else { 39 | finalSettings = settings; 40 | } 41 | 42 | // Attach change listeners before setting values 43 | attachChangeListeners(); 44 | 45 | // Load all settings into the form 46 | Object.keys(finalSettings).forEach(settingId => { 47 | const element = document.getElementById(settingId); 48 | if (element) { 49 | if (element.type === 'checkbox') { 50 | element.checked = finalSettings[settingId]; 51 | } else { 52 | element.value = finalSettings[settingId]; 53 | } 54 | } 55 | }); 56 | }); 57 | } catch (error) { 58 | console.error('Failed to load settings:', error); 59 | } 60 | } 61 | 62 | // Save a single setting 63 | function saveSetting(settingId, value) { 64 | chrome.storage.sync.get(null, (settings) => { 65 | const updatedSettings = { 66 | ...settings, 67 | [settingId]: value 68 | }; 69 | 70 | chrome.storage.sync.set(updatedSettings, () => { 71 | // Show temporary save indicator 72 | showSaveIndicator(); 73 | 74 | // Notify content script of settings change 75 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 76 | if (tabs[0]) { 77 | chrome.tabs.sendMessage(tabs[0].id, { 78 | type: 'settingsUpdated', 79 | settings: updatedSettings 80 | }); 81 | } 82 | }); 83 | }); 84 | }); 85 | } 86 | 87 | // Show temporary save indicator 88 | function showSaveIndicator() { 89 | const indicator = document.getElementById('autoSaveIndicator'); 90 | if (indicator) { 91 | indicator.textContent = 'Saved'; 92 | indicator.style.opacity = '1'; 93 | 94 | setTimeout(() => { 95 | indicator.style.opacity = '0'; 96 | }, 1000); 97 | } 98 | } 99 | 100 | // Attach change listeners to all settings 101 | function attachChangeListeners() { 102 | // Get all setting elements 103 | const settingElements = document.querySelectorAll('[id]'); 104 | 105 | settingElements.forEach(element => { 106 | // Remove existing listeners to avoid duplicates 107 | element.removeEventListener('change', handleSettingChange); 108 | element.removeEventListener('input', handleSettingChange); 109 | 110 | // Add new listeners 111 | element.addEventListener('change', handleSettingChange); 112 | 113 | // For text and number inputs, use debounced input handler 114 | if (element.type === 'text' || element.type === 'number') { 115 | element.addEventListener('input', debounce(handleSettingChange, 500)); 116 | } 117 | }); 118 | } 119 | 120 | // Handle setting change 121 | function handleSettingChange(event) { 122 | const element = event.target; 123 | const settingId = element.id; 124 | let value; 125 | 126 | switch (element.type) { 127 | case 'checkbox': 128 | value = element.checked; 129 | break; 130 | case 'number': 131 | value = parseInt(element.value, 10); 132 | break; 133 | default: 134 | value = element.value; 135 | } 136 | 137 | saveSetting(settingId, value); 138 | } 139 | 140 | // Debounce helper function 141 | function debounce(func, wait) { 142 | let timeout; 143 | return function executedFunction(...args) { 144 | const later = () => { 145 | clearTimeout(timeout); 146 | func(...args); 147 | }; 148 | clearTimeout(timeout); 149 | timeout = setTimeout(later, wait); 150 | }; 151 | } 152 | 153 | // Reset settings to defaults 154 | async function resetDefaults() { 155 | try { 156 | const defaults = await injectConfigScript(); 157 | chrome.storage.sync.set(defaults, () => { 158 | loadSettings(); 159 | // Notify content script 160 | chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { 161 | if (tabs[0]) { 162 | chrome.tabs.sendMessage(tabs[0].id, { 163 | type: 'settingsUpdated', 164 | settings: defaults 165 | }); 166 | } 167 | }); 168 | showSaveIndicator(); 169 | }); 170 | } catch (error) { 171 | console.error('Failed to reset settings:', error); 172 | } 173 | } 174 | 175 | // Initialize when DOM is loaded 176 | document.addEventListener('DOMContentLoaded', () => { 177 | loadSettings(); 178 | 179 | // Add reset defaults button listener 180 | const resetButton = document.getElementById('resetDefaults'); 181 | if (resetButton) { 182 | resetButton.addEventListener('click', resetDefaults); 183 | } 184 | 185 | // Remove the save button if it exists 186 | const saveButton = document.getElementById('saveSettings'); 187 | if (saveButton) { 188 | saveButton.remove(); 189 | } 190 | }); -------------------------------------------------------------------------------- /interceptor.js: -------------------------------------------------------------------------------- 1 | // Debug logger helper 2 | const debugLog = (message, data = "") => { 3 | console.log(`%c[Interceptor] ${message}`, "color: #00c853", data); 4 | }; 5 | 6 | class ContentInterceptor { 7 | constructor(selector, events = ["paste"], options = {}) { 8 | this.editorSelector = selector; 9 | this.editor = null; 10 | this.events = new Set(events); 11 | this.options = { 12 | lengthThreshold: 3999, 13 | openDelimiter: '', 14 | closeDelimiter: '', 15 | useDelimiters: true, 16 | escapeDelimiters: false, 17 | forceDelimiters: false, 18 | multilineOnly: true, 19 | autoRecover: true, 20 | recoveryInterval: 1000, 21 | enabled: true, 22 | allowDefault: false, 23 | interceptStandardPaste: false, 24 | ...options, 25 | }; 26 | 27 | if (!this.options.closeDelimiter) { 28 | this.options.closeDelimiter = this.options.openDelimiter; 29 | } 30 | 31 | this.recoveryTimer = null; 32 | debugLog("Interceptor initialized with options:", this.options); 33 | this.init(); 34 | } 35 | 36 | init() { 37 | debugLog("Starting initialization..."); 38 | this.waitForEditor().then(() => { 39 | debugLog("Editor found!"); 40 | this.setupEventListeners(); 41 | if (this.options.autoRecover) { 42 | this.startEditorMonitoring(); 43 | } 44 | }); 45 | } 46 | 47 | startEditorMonitoring() { 48 | this.stopEditorMonitoring(); // Clear any existing timer 49 | 50 | this.recoveryTimer = setInterval(() => { 51 | const currentEditor = document.querySelector(this.editorSelector); 52 | 53 | // Check if editor exists and has the expected properties 54 | if (!currentEditor?.editor || currentEditor !== this.editor) { 55 | debugLog("Editor lost or changed, attempting recovery..."); 56 | this.handleEditorLoss(); 57 | } 58 | }, this.options.recoveryInterval); 59 | 60 | debugLog("Editor monitoring started"); 61 | } 62 | 63 | stopEditorMonitoring() { 64 | if (this.recoveryTimer) { 65 | clearInterval(this.recoveryTimer); 66 | this.recoveryTimer = null; 67 | debugLog("Editor monitoring stopped"); 68 | } 69 | } 70 | 71 | handleEditorLoss() { 72 | debugLog("Handling editor loss..."); 73 | this.destroy(false); // Clean up but don't stop monitoring 74 | this.init(); // Reinitialize 75 | } 76 | 77 | waitForEditor() { 78 | return new Promise((resolve) => { 79 | const check = () => { 80 | const editorEl = document.querySelector(this.editorSelector); 81 | if (editorEl?.editor) { 82 | this.editor = editorEl; 83 | resolve(); 84 | } else { 85 | setTimeout(check, 100); 86 | } 87 | }; 88 | check(); 89 | }); 90 | } 91 | 92 | shouldUseDelimiters(content) { 93 | if (!this.options.useDelimiters) return false; 94 | if (this.options.forceDelimiters) return true; 95 | if (this.options.multilineOnly) { 96 | // Count actual newlines in the content 97 | const newlineCount = (content.match(/\n/g) || []).length; 98 | return newlineCount > 0 || content.length > this.options.lengthThreshold * 0.7; 99 | } 100 | return true; 101 | } 102 | 103 | setupEventListeners() { 104 | const keyHandler = (e) => { 105 | // Check for our custom shortcut (Cmd/Ctrl + Alt + W or Cmd/Ctrl + Shift + W) 106 | if ((e.metaKey || e.ctrlKey) && (e.altKey || e.shiftKey) && e.key.toLowerCase() === 'v') { 107 | debugLog('Custom paste shortcut detected'); 108 | e.preventDefault(); // Prevent any default behavior 109 | 110 | this.handleCustomPaste().then(async () => { 111 | if (this.options.allowDefault) { 112 | try { 113 | const text = await navigator.clipboard.readText(); 114 | if (text.length > this.options.lengthThreshold) { 115 | const pasteEvent = new ClipboardEvent('paste', { 116 | bubbles: true, 117 | cancelable: true, 118 | clipboardData: e.clipboardData 119 | }); 120 | this.editor.dispatchEvent(pasteEvent); 121 | } else { 122 | debugLog('Skipping native paste event - content below threshold'); 123 | } 124 | } catch (err) { 125 | debugLog('Error checking clipboard content:', err); 126 | } 127 | } 128 | }); 129 | } 130 | 131 | // Original paste handler (now optional based on settings) 132 | if ((e.metaKey || e.ctrlKey) && e.key === 'v') { 133 | debugLog('Standard paste shortcut detected'); 134 | 135 | // Only handle if interceptor is enabled and we want to intercept standard paste 136 | if (this.options.enabled && this.options.interceptStandardPaste) { 137 | if (!this.options.allowDefault) { 138 | e.preventDefault(); 139 | } 140 | 141 | this.handleCustomPaste().then(async () => { 142 | if (this.options.allowDefault) { 143 | try { 144 | const text = await navigator.clipboard.readText(); 145 | if (text.length > this.options.lengthThreshold) { 146 | const pasteEvent = new ClipboardEvent('paste', { 147 | bubbles: true, 148 | cancelable: true, 149 | clipboardData: e.clipboardData 150 | }); 151 | this.editor.dispatchEvent(pasteEvent); 152 | } else { 153 | debugLog('Skipping native paste event - content below threshold'); 154 | } 155 | } catch (err) { 156 | debugLog('Error checking clipboard content:', err); 157 | } 158 | } 159 | }); 160 | } 161 | } 162 | }; 163 | 164 | this.editor.addEventListener('keydown', keyHandler); 165 | this._keyHandler = keyHandler; // Store for cleanup 166 | } 167 | 168 | async handleCustomPaste() { 169 | try { 170 | // Get clipboard items 171 | const clipboardItems = await navigator.clipboard.read(); 172 | 173 | // Check if there are any text items 174 | const textItem = await clipboardItems.find((item) => item.types.includes("text/plain")); 175 | 176 | if (!textItem) { 177 | debugLog("No text content found in clipboard, skipping interception"); 178 | return; 179 | } 180 | 181 | const text = await navigator.clipboard.readText(); 182 | debugLog("Clipboard content length:", text.length); 183 | 184 | if (text.length > this.options.lengthThreshold) { 185 | this.handleLargePaste(text); 186 | } else { 187 | this.handleSmallPaste(text); 188 | } 189 | } catch (err) { 190 | debugLog("Clipboard access error:", err); 191 | } 192 | } 193 | 194 | escapeSpecialChars(text) { 195 | return text.replace(/[<>&'"]/g, (char) => { 196 | const escapeMap = { 197 | "<": "<", 198 | ">": ">", 199 | "&": "&", 200 | "'": "'", 201 | '"': """, 202 | }; 203 | return escapeMap[char]; 204 | }); 205 | } 206 | 207 | wrapWithDelimiters(content) { 208 | if (!this.shouldUseDelimiters(content)) { 209 | debugLog("Skipping delimiters"); 210 | return content; 211 | } 212 | 213 | const { openDelimiter, closeDelimiter, escapeDelimiters } = this.options; 214 | const open = escapeDelimiters ? this.escapeSpecialChars(openDelimiter) : openDelimiter; 215 | const close = escapeDelimiters ? this.escapeSpecialChars(closeDelimiter) : closeDelimiter; 216 | 217 | debugLog("Wrapping content with delimiters:", { open, close }); 218 | return `${open}\n${content}\n${close}`; 219 | } 220 | 221 | insertContentUsingProseMirror(content) { 222 | try { 223 | const editorInstance = this.editor.editor; 224 | const view = editorInstance.view; 225 | const { state } = view; 226 | const { from } = state.selection; 227 | 228 | debugLog("Initial selection position:", from); 229 | 230 | const wrappedContent = this.wrapWithDelimiters(content); 231 | const tr = state.tr; 232 | tr.insertText(wrappedContent, from); 233 | 234 | view.dispatch(tr); 235 | debugLog("Content inserted using ProseMirror"); 236 | return true; 237 | } catch (err) { 238 | debugLog("Error in primary insertion method:", err); 239 | return false; 240 | } 241 | } 242 | 243 | insertContentAltMethod(content) { 244 | try { 245 | const editorInstance = this.editor.editor; 246 | const view = editorInstance.view; 247 | const { state } = view; 248 | const { from } = state.selection; 249 | 250 | const wrappedContent = this.wrapWithDelimiters(content); 251 | const text = state.schema.text(wrappedContent); 252 | const tr = state.tr.insert(from, text); 253 | 254 | view.dispatch(tr); 255 | debugLog("Content inserted using alternative method"); 256 | return true; 257 | } catch (err) { 258 | debugLog("Error in alternative insertion method:", err); 259 | return false; 260 | } 261 | } 262 | 263 | insertContentFallback(content) { 264 | try { 265 | const wrappedContent = this.wrapWithDelimiters(content); 266 | document.execCommand("insertText", false, wrappedContent); 267 | debugLog("Content inserted using fallback method"); 268 | return true; 269 | } catch (err) { 270 | debugLog("All insertion methods failed:", err); 271 | return false; 272 | } 273 | } 274 | 275 | handleSmallPaste(content) { 276 | debugLog("Processing small paste"); 277 | const processed = this.processSmallContent(content); 278 | 279 | // Try all methods in sequence until one succeeds 280 | if (!this.insertContentUsingProseMirror(processed) && !this.insertContentAltMethod(processed)) { 281 | this.insertContentFallback(processed); 282 | } 283 | } 284 | 285 | handleLargePaste(content) { 286 | debugLog("Processing large paste"); 287 | const processed = this.processLargeContent(content); 288 | 289 | if (!this.insertContentUsingProseMirror(processed) && !this.insertContentAltMethod(processed)) { 290 | this.insertContentFallback(processed); 291 | } 292 | } 293 | 294 | detectIfCode(content) { 295 | const codeIndicators = [ 296 | /^(const|let|var|function|class|import|if|for|while)\b/m, 297 | /^def\s|^class\s|^import\s/m, 298 | /[{}\[\]()];/, 299 | /\b(public|private|protected)\b/, 300 | /^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*[=:]/m, 301 | ]; 302 | return codeIndicators.some((pattern) => pattern.test(content)); 303 | } 304 | 305 | processSmallContent(content) { 306 | return content; 307 | } 308 | 309 | processLargeContent(content) { 310 | return content; 311 | } 312 | 313 | destroy(stopMonitoring = true) { 314 | if (stopMonitoring) { 315 | this.stopEditorMonitoring(); 316 | } 317 | 318 | if (this.editor && this._pasteHandler) { 319 | this.editor.removeEventListener("keydown", this._pasteHandler); 320 | } 321 | 322 | debugLog("Interceptor cleanup completed"); 323 | } 324 | } 325 | 326 | // Add this at the end of interceptor.js 327 | window.__initializeInterceptor = function (settings) { 328 | if (window._interceptor) { 329 | window._interceptor.destroy(); 330 | } 331 | window._interceptor = new ContentInterceptor('div[contenteditable="true"]', ["paste"], settings); 332 | }; 333 | 334 | // Listen for messages from the content script 335 | window.addEventListener("message", function (event) { 336 | // Make sure the message is from our extension 337 | if (event.data.type === "INTERCEPTOR_SETTINGS") { 338 | window.__initializeInterceptor(event.data.settings); 339 | } 340 | }); 341 | --------------------------------------------------------------------------------