diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..78d7ecd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "Split Translator Development", + "image": "mcr.microsoft.com/devcontainers/javascript-node:22-bookworm", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "postCreateCommand": "npm install", + "customizations": { + "vscode": { + "extensions": [ + "github.vscode-github-actions", + "ms-vscode.vscode-typescript-next" + ] + } + }, + "remoteUser": "node" +} \ No newline at end of file diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..947e8ad --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,33 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install JavaScript dependencies + run: npm install + + - name: Build TypeScript project + run: npm run build diff --git a/README.md b/README.md index bf76b57..ad387d5 100644 --- a/README.md +++ b/README.md @@ -110,21 +110,57 @@ split-translator/ | **Screen Resolution** | Minimum width of 800px is recommended | | **Browser Version** | Chrome 88+, Edge 88+ (Manifest V3 required) | -## 🛠️ Development & Customization +## 🛠️ Development & Building -### Adjusting Window Overlap -```javascript -const OVERLAP_PIXELS = 8; // Adjustable in background.js +This extension is built with **TypeScript** for enhanced type safety and maintainability. + +### Prerequisites +- Node.js (v22 or higher) +- npm + +### Building from Source +```bash +# Install dependencies +npm install + +# Build TypeScript to JavaScript +npm run build + +# Clean build directory +npm run clean + +# Watch for changes during development +npm run watch + +# Build and package for distribution +npm run package +``` + +### Project Structure +``` +src/ + ├── background.ts # Service worker (TypeScript) + ├── popup.ts # Popup script (TypeScript) +dist/ + ├── background.js # Compiled service worker + ├── popup.js # Compiled popup script +``` + +### Development & Customization + +#### Adjusting Window Overlap +```typescript +const OVERLAP_PIXELS = 8; // Adjustable in src/background.ts ``` -### Adding a Language +#### Adding a Language ```html ``` -### Timeout Setting -```javascript +#### Timeout Setting +```typescript await waitForTabReady(tabId, 3000); // 3 seconds timeout ``` diff --git a/build-package.bat b/build-package.bat index f3173cc..cd10d06 100644 --- a/build-package.bat +++ b/build-package.bat @@ -13,7 +13,7 @@ if exist "dist\split-translator.zip" del "dist\split-translator.zip" echo [*] Creating package... REM Create ZIP file using PowerShell -powershell -Command "& { Add-Type -AssemblyName System.IO.Compression.FileSystem; $zip = [System.IO.Compression.ZipFile]::Open('dist\split-translator.zip', 'Create'); $files = @('manifest.json', 'popup.html', 'popup.js', 'background.js', 'LICENSE', 'PRIVACY_POLICY.md'); foreach ($file in $files) { if (Test-Path $file) { [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file, $file) | Out-Null; Write-Host \"+ $file\" } }; if (Test-Path 'icons') { Get-ChildItem 'icons\*.png' | ForEach-Object { [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, 'icons\' + $_.Name) | Out-Null; Write-Host \"+ icons\$($_.Name)\" } }; $zip.Dispose() }" +powershell -Command "& { Add-Type -AssemblyName System.IO.Compression.FileSystem; $zip = [System.IO.Compression.ZipFile]::Open('dist\split-translator.zip', 'Create'); $files = @('manifest.json', 'popup.html', 'dist\popup.js', 'dist\background.js', 'LICENSE', 'PRIVACY_POLICY.md'); foreach ($file in $files) { if (Test-Path $file) { [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $file, $file) | Out-Null; Write-Host \"+ $file\" } }; if (Test-Path 'icons') { Get-ChildItem 'icons\*.png' | ForEach-Object { [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, 'icons\' + $_.Name) | Out-Null; Write-Host \"+ icons\$($_.Name)\" } }; $zip.Dispose() }" echo. echo [OK] Package created successfully: dist\split-translator.zip diff --git a/manifest.json b/manifest.json index 2cc9e3a..f7252ff 100644 --- a/manifest.json +++ b/manifest.json @@ -22,7 +22,7 @@ } }, "background": { - "service_worker": "background.js" + "service_worker": "dist/background.js" }, "icons": { "16": "icons/icon-16.png", diff --git a/package.json b/package.json index cc73da6..91f223e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,11 @@ "url": "https://github.com/SIkebe/split-translator/issues" }, "scripts": { - "package": "build-package.bat" + "build": "tsc", + "watch": "tsc --watch --preserveWatchOutput", + "package": "npm run build && build-package.bat", + "clean": "rimraf dist", + "dev": "npm run clean && npm run build && echo 'Development build complete. Load dist/ folder in Chrome Extensions.'" }, "keywords": [ "browser-extension", @@ -31,13 +35,15 @@ }, "license": "MIT", "devDependencies": { - "@types/chrome": "0.0.329" + "@types/chrome": "0.0.329", + "rimraf": "6.0.1", + "typescript": "5.8.3" }, "files": [ "manifest.json", "popup.html", - "popup.js", - "background.js", + "dist/popup.js", + "dist/background.js", "icons/", "LICENSE", "PRIVACY_POLICY.md", diff --git a/popup.html b/popup.html index 7e00244..07bfdb1 100644 --- a/popup.html +++ b/popup.html @@ -228,6 +228,6 @@

Split Translator

- + diff --git a/background.js b/src/background.ts similarity index 76% rename from background.js rename to src/background.ts index 7368db1..4b58635 100644 --- a/background.js +++ b/src/background.ts @@ -1,12 +1,16 @@ // Background script (Service Worker) +/// + // Constants const OVERLAP_PIXELS = 8; // Compensate for window frame gaps const MIN_WINDOW_WIDTH = 400; // Minimum window width in pixels const MIN_WINDOW_HEIGHT = 300; // Minimum window height in pixels +const DEFAULT_WINDOW_WIDTH = 800; // Default window width when current window width is unavailable +const DEFAULT_WINDOW_HEIGHT = 600; // Default window height when current window height is unavailable // Common error handler -function handleError(error, context) { +function handleError(error: Error, context: string): { success: false; error: string } { console.error(`${context}:`, error); return { success: false, @@ -15,7 +19,7 @@ function handleError(error, context) { } // Message listener -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { +chrome.runtime.onMessage.addListener((request: SplitAndTranslateMessage, sender, sendResponse) => { if (request.action === 'splitAndTranslate') { handleSplitAndTranslate(request.currentTab, request.targetLanguage) .then(result => sendResponse(result)) @@ -25,7 +29,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { }); // Split view handling -async function handleSplitView(currentTab, targetLanguage) { +async function handleSplitView(currentTab: chrome.tabs.Tab, targetLanguage: string): Promise<{ success: true }> { try { console.log('Starting split view:', currentTab); @@ -44,8 +48,8 @@ async function handleSplitView(currentTab, targetLanguage) { 'file://', ]; - if (UNSUPPORTED_PREFIXES.some(prefix => currentTab.url.startsWith(prefix)) || - currentTab.url.includes('translate.goog')) { + if (UNSUPPORTED_PREFIXES.some(prefix => currentTab.url!.startsWith(prefix)) || + currentTab.url!.includes('translate.goog')) { throw new Error('This page type cannot be translated. Please try on a regular website.'); } @@ -71,14 +75,14 @@ async function handleSplitView(currentTab, targetLanguage) { const rightWidth = halfWidth + OVERLAP_PIXELS; // Calculate positions (use entire display) - const leftPosition = { + const leftPosition: WindowPosition = { left: displayLeft, top: displayTop, width: leftWidth, height: displayHeight }; - const rightPosition = { + const rightPosition: WindowPosition = { left: displayLeft + halfWidth - OVERLAP_PIXELS, top: displayTop, width: rightWidth, @@ -96,6 +100,10 @@ async function handleSplitView(currentTab, targetLanguage) { state: 'normal' }); + if (!rightWindow || !rightWindow.tabs || !rightWindow.tabs[0]?.id || !rightWindow.id) { + throw new Error('Failed to create right window'); + } + // Resize left window await chrome.windows.update(currentTab.windowId, { ...leftPosition, @@ -103,8 +111,8 @@ async function handleSplitView(currentTab, targetLanguage) { }); // Save data - const splitViewData = { - originalTabId: currentTab.id, + const splitViewData: SplitViewData = { + originalTabId: currentTab.id!, duplicatedTabId: rightWindow.tabs[0].id, targetLanguage: targetLanguage, originalWindowId: currentTab.windowId, @@ -122,12 +130,12 @@ async function handleSplitView(currentTab, targetLanguage) { } // Execute split view and translation at once -async function handleSplitAndTranslate(currentTab, targetLanguage) { +async function handleSplitAndTranslate(currentTab: chrome.tabs.Tab, targetLanguage: string): Promise<{ success: true }> { try { console.log('Starting split view + translation:', currentTab); // Prepare translation URL in advance (for parallel processing) - const translateUrl = `https://translate.google.com/translate?sl=auto&tl=${targetLanguage}&u=${encodeURIComponent(currentTab.url)}`; + const translateUrl = `https://translate.google.com/translate?sl=auto&tl=${targetLanguage}&u=${encodeURIComponent(currentTab.url!)}`; // 1. Execute split view await handleSplitView(currentTab, targetLanguage); @@ -148,7 +156,7 @@ async function handleSplitAndTranslate(currentTab, targetLanguage) { const currentUrl = rightTab.url; // Do nothing if URL is already Google Translate page - if (currentUrl.includes('translate.google.com')) { + if (currentUrl && currentUrl.includes('translate.google.com')) { console.log('Right tab is already a Google Translate page'); return { success: true }; } @@ -176,7 +184,7 @@ async function handleSplitAndTranslate(currentTab, targetLanguage) { } // Get display information (helper function) -async function getDisplayInfo() { +async function getDisplayInfo(): Promise { if (!chrome.system?.display) return []; return new Promise((resolve) => { @@ -192,7 +200,7 @@ async function getDisplayInfo() { } // Wait for tab to finish loading (helper function) -async function waitForTabReady(tabId, maxWaitTime = 3000) { +async function waitForTabReady(tabId: number, maxWaitTime: number = 3000): Promise { const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { try { @@ -206,7 +214,7 @@ async function waitForTabReady(tabId, maxWaitTime = 3000) { } // Helper function to enforce minimum dimensions -function enforceMinimumDimensions(bounds) { +function enforceMinimumDimensions(bounds: DisplayBounds): DisplayBounds { return { ...bounds, width: Math.max(bounds.width, MIN_WINDOW_WIDTH), @@ -215,22 +223,22 @@ function enforceMinimumDimensions(bounds) { } // Get display bounds (helper function) -function getDisplayBounds(displays, currentWindow) { - let bounds; +function getDisplayBounds(displays: any[], currentWindow: chrome.windows.Window): DisplayBounds { + let bounds: DisplayBounds; if (!displays || !displays.length) { console.warn('No display information available, using current window bounds'); bounds = { - left: currentWindow.left, // Preserve current window position - top: currentWindow.top, // Preserve current window position - width: currentWindow.width, - height: currentWindow.height + left: currentWindow.left ?? 0, // Preserve current window position + top: currentWindow.top ?? 0, // Preserve current window position + width: currentWindow.width ?? DEFAULT_WINDOW_WIDTH, + height: currentWindow.height ?? DEFAULT_WINDOW_HEIGHT }; } else { // Find the display to which the current window belongs // Use window center point for accurate detection - const windowCenterX = currentWindow.left + (currentWindow.width / 2); - const windowCenterY = currentWindow.top + (currentWindow.height / 2); + const windowCenterX = (currentWindow.left ?? 0) + ((currentWindow.width ?? DEFAULT_WINDOW_WIDTH) / 2); + const windowCenterY = (currentWindow.top ?? 0) + ((currentWindow.height ?? DEFAULT_WINDOW_HEIGHT) / 2); const display = displays.find(d => windowCenterX >= d.workArea.left && diff --git a/popup.js b/src/popup.ts similarity index 78% rename from popup.js rename to src/popup.ts index 0791478..02431b3 100644 --- a/popup.js +++ b/src/popup.ts @@ -1,8 +1,11 @@ // Popup script + +/// + document.addEventListener('DOMContentLoaded', function() { - const splitAndTranslateButton = document.getElementById('splitAndTranslate'); - const targetLanguageSelect = document.getElementById('targetLanguage'); - const statusDiv = document.getElementById('status'); + const splitAndTranslateButton = document.getElementById('splitAndTranslate') as HTMLButtonElement; + const targetLanguageSelect = document.getElementById('targetLanguage') as HTMLSelectElement; + const statusDiv = document.getElementById('status') as HTMLDivElement; // Initialize status text span (once only) const statusTextSpan = document.createElement('span'); @@ -10,7 +13,7 @@ document.addEventListener('DOMContentLoaded', function() { statusDiv.appendChild(statusTextSpan); // Helper function to update status with proper accessibility - function updateStatus(message, type = 'info') { + function updateStatus(message: string, type: 'info' | 'error' | 'success' = 'info'): void { statusTextSpan.textContent = message; statusDiv.classList.remove('info', 'error', 'success'); statusDiv.classList.add(type); @@ -29,7 +32,7 @@ document.addEventListener('DOMContentLoaded', function() { setTimeout(() => targetLanguageSelect.focus(), 100); // Load saved language settings - chrome.storage.sync.get(['targetLanguage'], function(result) { + chrome.storage.sync.get(['targetLanguage'], function(result: { targetLanguage?: string }) { if (result.targetLanguage) { targetLanguageSelect.value = result.targetLanguage; } @@ -42,12 +45,13 @@ document.addEventListener('DOMContentLoaded', function() { }); // Announce language change to screen readers (simpler message) - updateStatus(`Language changed to ${targetLanguageSelect.options[targetLanguageSelect.selectedIndex].text}`, 'info'); + const selectedOption = targetLanguageSelect.options[targetLanguageSelect.selectedIndex]; + updateStatus(`Language changed to ${selectedOption.text}`, 'info'); }); // Helper function to get current focusable elements - function getFocusableElements() { - const elements = document.querySelectorAll( + function getFocusableElements(): FocusableElements { + const elements = document.querySelectorAll( 'button, select, input, [tabindex]:not([tabindex="-1"])' ); return { @@ -58,7 +62,7 @@ document.addEventListener('DOMContentLoaded', function() { } // Unified keyboard navigation support - document.addEventListener('keydown', function(event) { + document.addEventListener('keydown', function(event: KeyboardEvent) { // Handle Escape key to close popup if (event.key === 'Escape') { window.close(); @@ -72,10 +76,10 @@ document.addEventListener('DOMContentLoaded', function() { if (focusable.first && focusable.last) { if (event.shiftKey && document.activeElement === focusable.first) { event.preventDefault(); - focusable.last.focus(); + (focusable.last as HTMLElement).focus(); } else if (!event.shiftKey && document.activeElement === focusable.last) { event.preventDefault(); - focusable.first.focus(); + (focusable.first as HTMLElement).focus(); } } } @@ -102,7 +106,7 @@ document.addEventListener('DOMContentLoaded', function() { action: 'splitAndTranslate', currentTab: currentTab, targetLanguage: targetLanguageSelect.value - }); + } as SplitAndTranslateMessage) as SplitAndTranslateResponse; if (response.success) { updateStatus('Split + translation completed successfully!', 'success'); @@ -116,7 +120,8 @@ document.addEventListener('DOMContentLoaded', function() { } } catch (error) { console.error('Split + translation error:', error); - updateStatus(`Error: ${error.message}`, 'error'); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + updateStatus(`Error: ${errorMessage}`, 'error'); } finally { splitAndTranslateButton.disabled = false; splitAndTranslateButton.removeAttribute('aria-busy'); diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..5169bc9 --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,42 @@ +// Shared type definitions for Split Translator extension + +// Response interfaces +interface SplitAndTranslateResponse { + success: boolean; + error?: string; +} + +// Message interfaces +interface SplitAndTranslateMessage { + action: 'splitAndTranslate'; + currentTab: chrome.tabs.Tab; + targetLanguage: string; +} + +// Data interfaces +interface SplitViewData { + originalTabId: number; + duplicatedTabId: number; + targetLanguage: string; + originalWindowId: number; + duplicatedWindowId: number; +} + +// Geometry interfaces +interface Bounds { + left: number; + top: number; + width: number; + height: number; +} + +interface WindowPosition extends Bounds {} + +interface DisplayBounds extends Bounds {} + +// UI interfaces +interface FocusableElements { + elements: NodeListOf; + first: Element | null; + last: Element | null; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8b8bd36 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "None", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "types": ["chrome"], + "lib": ["ES2020", "DOM"], + "allowJs": false, + "checkJs": false, + "noEmitOnError": true, + "removeComments": true, + "isolatedModules": false, + "verbatimModuleSyntax": false + }, + "watchOptions": { + "watchFile": "priorityPollingInterval", + "watchDirectory": "useFsEvents", + "fallbackPolling": "priorityInterval", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "**/dist"] + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +}