├── .gitignore ├── src ├── offscreen │ ├── offscreen.html │ └── offscreen.js ├── options │ ├── options.js │ └── options.html └── background.js ├── siat-screenshot.png ├── .github └── PULL_REQUEST_TEMPLATE │ └── add-locale.md ├── _locales ├── zh_CN │ └── messages.json ├── zh_TW │ └── messages.json ├── ja │ └── messages.json ├── ko │ └── messages.json ├── ar │ └── messages.json ├── pt_BR │ └── messages.json ├── pt_PT │ └── messages.json ├── es │ └── messages.json ├── vi │ └── messages.json ├── ru │ └── messages.json ├── de │ └── messages.json ├── uk │ └── messages.json ├── it │ └── messages.json ├── fr │ └── messages.json └── en │ └── messages.json ├── manifest.json ├── LICENSE ├── assets └── icons │ ├── icon-128.svg │ ├── icon-16.svg │ └── icon-48.svg ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | web-ext-artifacts/ 2 | 3 | -------------------------------------------------------------------------------- /src/offscreen/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /siat-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d7omdev/Save-Image-as-Type/HEAD/siat-screenshot.png -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/add-locale.md: -------------------------------------------------------------------------------- 1 | # Add New Locale 2 | 3 | ## Language 4 | 5 | - **Language Name**: [e.g., French] 6 | - **Locale Code**: [e.g., `fr`] 7 | 8 | ## Changes 9 | 10 | - [ ] Added new locale folder: `_locales/{locale_code}/` 11 | - [ ] Translated all strings in `messages.json` 12 | - [ ] Tested the extension with the new locale 13 | 14 | ## Screenshots (Optional) 15 | 16 | Add screenshots of the extension UI in the new language. 17 | 18 | ## Checklist 19 | 20 | - [ ] I have tested the translation in the extension. 21 | 22 | ## Related Issues 23 | 24 | Closes #[issue_number] (if applicable) 25 | -------------------------------------------------------------------------------- /_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "图片另存为JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "图片另存为" 7 | }, 8 | "extDescription": { 9 | "message": "为图片添加右键菜单:另存为PNG,另存为JPG,另存为WebP." 10 | }, 11 | "Save_as": { 12 | "message": "另存为 $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "在应用商店查看" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "目标不是图片" 25 | }, 26 | "errorOnSaving": { 27 | "message": "保存图片文件时发生错误" 28 | }, 29 | "errorOnLoading": { 30 | "message": "加载图片文件时发生错误" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "圖片另存為JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "圖片另存為" 7 | }, 8 | "extDescription": { 9 | "message": "為圖片添加右鍵菜單:另存為PNG,另存為JPG,另存為WebP." 10 | }, 11 | "Save_as": { 12 | "message": "另存為 $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "在應用商店查看" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "目標不是圖片" 25 | }, 26 | "errorOnSaving": { 27 | "message": "保存圖片文件時發生錯誤" 28 | }, 29 | "errorOnLoading": { 30 | "message": "加載圖片文件時發生錯誤" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "画像を JPG/PNG/WebP として保存" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "画像のコンテキスト メニューで、画像を PNG、JPG、または WebP として保存します。" 10 | }, 11 | "Save_as": { 12 | "message": "$FORMAT$ として保存", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "ストアで見る" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "画像じゃないです" 25 | }, 26 | "errorOnSaving": { 27 | "message": "画像の保存中にエラーが発生しました" 28 | }, 29 | "errorOnLoading": { 30 | "message": "画像の読み込み中にエラーが発生しました" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/ko/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "사진을 JPG/PNG/WebP로 저장" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "이미지의 컨텍스트 메뉴를 통해 이미지를 PNG, JPG 또는 WebP로 저장합니다" 10 | }, 11 | "Save_as": { 12 | "message": "$FORMAT$ 로 저장", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "매장에서 보기" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "이미지가 아닙니다" 25 | }, 26 | "errorOnSaving": { 27 | "message": "이미지를 저장하는 동안 오류가 발생했습니다" 28 | }, 29 | "errorOnLoading": { 30 | "message": "이미지를 로드하는 동안 오류가 발생했습니다" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/ar/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "احفظ الصورة بتنسيق JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "احفظ الصورة بتنسيق PNG أو JPG أو WebP من خلال قائمة السياق على الصورة." 10 | }, 11 | "Save_as": { 12 | "message": "حفظ باسم $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "عرض في المتجر" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "إنها ليست صورة" 25 | }, 26 | "errorOnSaving": { 27 | "message": "حدث خطأ أثناء حفظ الصورة" 28 | }, 29 | "errorOnLoading": { 30 | "message": "حدث خطأ أثناء تحميل الصورة" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Salvar imagem como Tipo" 4 | }, 5 | "extShortName": { 6 | "message": "SalvarImagemComo" 7 | }, 8 | "extDescription": { 9 | "message": "Salve imagem como PNG, JPG ou WebP por menu de contexto na imagem." 10 | }, 11 | "Save_as": { 12 | "message": "Salvar como $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Ver na loja" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Não é uma imagem" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Ocorreu um erro ao salvar imagem" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Ocorreu um erro ao carregar imagem" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/pt_PT/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Guardar imagem como Tipo" 4 | }, 5 | "extShortName": { 6 | "message": "GuardarImagemComo" 7 | }, 8 | "extDescription": { 9 | "message": "Guarde a imagem como PNG, JPG ou WebP por menu de contexto na imagem." 10 | }, 11 | "Save_as": { 12 | "message": "Guardar como $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Ver na loja" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Não é uma imagem" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Ocorreu um erro ao guardar a imagem" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Ocorreu um erro ao carregar a imagem" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Guardar imagen como JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Guarde la imagen como PNG, JPG o WebP mediante el menú contextual de la imagen." 10 | }, 11 | "Save_as": { 12 | "message": "Guardar como $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Ver en la tienda" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "no es una imagen" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Ocurrió un error al guardar la imagen." 28 | }, 29 | "errorOnLoading": { 30 | "message": "Ocurrió un error al cargar la imagen." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/vi/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Lưu hình ảnh dưới dạng JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Lưu hình ảnh dưới dạng PNG, JPG hoặc WebP bằng menu ngữ cảnh trên hình ảnh." 10 | }, 11 | "Save_as": { 12 | "message": "Lưu dưới dạng $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Xem tại cửa hàng" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Nó không phải là một hình ảnh" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Đã xảy ra lỗi khi lưu hình ảnh" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Đã xảy ra lỗi khi tải hình ảnh" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Сохранить изображение как" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Сохраните изображение как PNG, JPG или WebP с помощью контекстного меню на изображении." 10 | }, 11 | "Save_as": { 12 | "message": "Сохранить как $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Открыть в магазине" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Это не изображение" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Произошла ошибка при сохранении изображения" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Произошла ошибка при загрузке изображения" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Save image as Type" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Speichere Bilder als PNG-, JPG- oder WebP-Dateien mit einem Rechtsklick auf ein Bild." 10 | }, 11 | "Save_as": { 12 | "message": "Speichere als $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Im Store ansehen" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Das ist kein Bild." 25 | }, 26 | "errorOnSaving": { 27 | "message": "Beim Speichern des Bildes ist ein Fehler aufgetreten." 28 | }, 29 | "errorOnLoading": { 30 | "message": "Beim Laden des Bildes ist ein Fehler aufgetreten." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/uk/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Зберегти зображення як" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Збережіть зображення у форматі PNG, JPG або WebP за допомогою контекстного меню на зображенні." 10 | }, 11 | "Save_as": { 12 | "message": "Зберегти як $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Переглянути в магазині" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Це не зображення" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Під час збереження зображення сталася помилка" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Під час завантаження зображення сталася помилка" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Salva l'immagine come JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Salva l'immagine come PNG, JPG o WebP dal menu contestuale sull'immagine." 10 | }, 11 | "Save_as": { 12 | "message": "Salva come $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Visualizza in negozio" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Non è un'immagine" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Si è verificato un errore durante il salvataggio dell'immagine" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Si è verificato un errore durante il caricamento dell'immagine" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Enregistrer l'image sous JPG/PNG/WebP" 4 | }, 5 | "extShortName": { 6 | "message": "SaveImageAs" 7 | }, 8 | "extDescription": { 9 | "message": "Enregistrez l'image au format PNG, JPG ou WebP par le menu contextuel sur l'image." 10 | }, 11 | "Save_as": { 12 | "message": "Enregistrer au format $FORMAT$", 13 | "placeholders": { 14 | "format": { 15 | "content": "$1", 16 | "example": "PNG" 17 | } 18 | } 19 | }, 20 | "View_in_store": { 21 | "message": "Afficher sur le store" 22 | }, 23 | "errorIsNotImage": { 24 | "message": "Ce n'est pas une image" 25 | }, 26 | "errorOnSaving": { 27 | "message": "Une erreur s'est produite lors de l'enregistrement de l'image" 28 | }, 29 | "errorOnLoading": { 30 | "message": "Une erreur s'est produite lors du chargement de l'image" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_extName__", 4 | "short_name": "__MSG_extShortName__", 5 | "description": "__MSG_extDescription__", 6 | "version": "1.1.3", 7 | "default_locale": "en", 8 | "browser_specific_settings": { 9 | "gecko": { 10 | "id": "SaveImageAsType@d7om.dev", 11 | "strict_min_version": "110.0" 12 | } 13 | }, 14 | "icons": { 15 | "16": "assets/icons/icon-16.svg", 16 | "48": "assets/icons/icon-48.svg", 17 | "128": "assets/icons/icon-128.svg" 18 | }, 19 | "background": { 20 | "scripts": [ 21 | "src/background.js" 22 | ] 23 | }, 24 | "options_ui": { 25 | "page": "src/options/options.html", 26 | "open_in_tab": false 27 | }, 28 | "permissions": [ 29 | "downloads", 30 | "contextMenus", 31 | "activeTab", 32 | "scripting", 33 | "storage", 34 | "offscreen" 35 | ], 36 | "host_permissions": [ 37 | "" 38 | ], 39 | "homepage_url": "https://github.com/d7omdev/Save-Image-as-Type" 40 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright © 2025 D7OM 5 | 6 | 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: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | 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. 11 | -------------------------------------------------------------------------------- /assets/icons/icon-128.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/icons/icon-16.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/icons/icon-48.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/options/options.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", restoreOptions); 2 | document.getElementById("save").addEventListener("click", saveOptions); 3 | document.getElementById("reset").addEventListener("click", resetOptions); 4 | 5 | function showToast(message) { 6 | const toast = document.getElementById("toast"); 7 | toast.textContent = message; 8 | toast.classList.add("visible"); 9 | setTimeout(() => toast.classList.remove("visible"), 2000); 10 | } 11 | 12 | function saveOptions(e) { 13 | e.preventDefault(); 14 | browser.storage.sync 15 | .set({ 16 | defaultType: document.getElementById("defaultType").value, 17 | showStoreButton: document.getElementById("showStoreButton").checked, 18 | }) 19 | .then(() => { 20 | showToast("Options saved!"); 21 | }); 22 | } 23 | 24 | function restoreOptions() { 25 | browser.storage.sync 26 | .get({ 27 | defaultType: "", 28 | showStoreButton: false, 29 | }) 30 | .then((items) => { 31 | document.getElementById("defaultType").value = items.defaultType; 32 | document.getElementById("showStoreButton").checked = 33 | items.showStoreButton; 34 | }); 35 | } 36 | 37 | function resetOptions() { 38 | browser.storage.sync 39 | .set({ 40 | defaultType: "", 41 | showStoreButton: false, 42 | }) 43 | .then(() => { 44 | restoreOptions(); 45 | showToast("Options reset!"); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extName": { 3 | "message": "Save Image As Type", 4 | "description": "Extension name" 5 | }, 6 | "extShortName": { 7 | "message": "SIAT", 8 | "description": "Extension short name" 9 | }, 10 | "extDescription": { 11 | "message": "Save images as JPG, PNG, or WebP with a single click", 12 | "description": "Extension description" 13 | }, 14 | "Save_as": { 15 | "message": "Save as $TYPE$", 16 | "description": "Save as menu item", 17 | "placeholders": { 18 | "type": { 19 | "content": "$1", 20 | "example": "JPG" 21 | } 22 | } 23 | }, 24 | "Save_image_as": { 25 | "message": "Save image as", 26 | "description": "Save image as menu item" 27 | }, 28 | "Options": { 29 | "message": "Options", 30 | "description": "Options menu item" 31 | }, 32 | "View_in_store": { 33 | "message": "View in store", 34 | "description": "View in store menu item" 35 | }, 36 | "errorOnSaving": { 37 | "message": "Error occurred while saving the image", 38 | "description": "Error message when saving fails" 39 | }, 40 | "errorOnLoading": { 41 | "message": "Error occurred while loading the image", 42 | "description": "Error message when loading fails" 43 | }, 44 | "errorIsNotImage": { 45 | "message": "The selected element is not an image", 46 | "description": "Error message when selected element is not an image" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "save-image-as-type", 3 | "version": "1.1.3", 4 | "description": "A Firefox extension to save images in your preferred format.", 5 | "scripts": { 6 | "start": "web-ext run --devtools", 7 | "build": "web-ext build", 8 | "lint": "web-ext lint", 9 | "test": "web-ext run --pref extensions.experiments.enabled=true", 10 | "clean": "rm -rf web-ext-artifacts/", 11 | "prepare": "npm run clean && npm run build", 12 | "sign": "web-ext sign --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET", 13 | "version:patch": "npm version patch --no-git-tag-version && node -e \"const fs=require('fs'); const manifest=JSON.parse(fs.readFileSync('manifest.json')); const pkg=JSON.parse(fs.readFileSync('package.json')); manifest.version=pkg.version; fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2));\"", 14 | "version:minor": "npm version minor --no-git-tag-version && node -e \"const fs=require('fs'); const manifest=JSON.parse(fs.readFileSync('manifest.json')); const pkg=JSON.parse(fs.readFileSync('package.json')); manifest.version=pkg.version; fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2));\"", 15 | "version:major": "npm version major --no-git-tag-version && node -e \"const fs=require('fs'); const manifest=JSON.parse(fs.readFileSync('manifest.json')); const pkg=JSON.parse(fs.readFileSync('package.json')); manifest.version=pkg.version; fs.writeFileSync('manifest.json', JSON.stringify(manifest, null, 2));\"" 16 | }, 17 | "keywords": [ 18 | "firefox", 19 | "extension", 20 | "image", 21 | "download" 22 | ], 23 | "author": "D7OM ", 24 | "license": "MPL-2.0", 25 | "devDependencies": { 26 | "web-ext": "^7.6.0" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/d7omdev/Save-Image-as-Type.git" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/d7omdev/Save-Image-as-Type/issues" 34 | }, 35 | "homepage": "https://github.com/d7omdev/Save-Image-as-Type#readme" 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Save Image as Type - Firefox Extension 2 | 3 | ## Overview 4 | 5 | Save Image as Type is a Firefox extension that adds a **Save Image as PNG / JPG / WebP** option to the right-click context menu of images. This allows users to quickly save images in their preferred format without needing additional conversion tools. 6 | 7 | This is a Firefox-compatible port of [Save-Image-as-Type](https://github.com/image4tools/Save-Image-as-Type) with modifications for better compatibility and organization. 8 | 9 |  10 | 11 | ### [Install from Firefox Add-ons](https://addons.mozilla.org/firefox/addon/siat/) 12 | 13 | --- 14 | 15 | ## Installation 16 | 17 | ### Install from Firefox Add-ons 18 | 19 | 1. Visit the [Firefox Add-ons page](https://addons.mozilla.org/firefox/addon/siat/). 20 | 2. Click **Add to Firefox**. 21 | 3. Follow the prompts to install the extension. 22 | 23 | ### Install from Source 24 | 25 | 1. Clone the repository: 26 | ```sh 27 | git clone https://github.com/d7om/Save-Image-as-Type.git 28 | cd Save-Image-as-Type 29 | ``` 30 | 2. Load the extension in Firefox: 31 | - Open `about:debugging` in Firefox. 32 | - Click **This Firefox**. 33 | - Click **Load Temporary Add-on**. 34 | - Select the `manifest.json` file inside the project folder. 35 | 36 | --- 37 | 38 | ## Development Setup 39 | 40 | ### Running in Development Mode 41 | 42 | 1. Install [web-ext](https://github.com/mozilla/web-ext) if you haven't: 43 | ```sh 44 | npm install 45 | ``` 46 | 2. Run the extension in a temporary Firefox instance: 47 | ```sh 48 | npm start 49 | ``` 50 | 3. Make changes and reload the extension as needed. 51 | 52 | ### Building the Extension 53 | 54 | To package the extension for distribution: 55 | 56 | ```sh 57 | npm run build 58 | ``` 59 | 60 | --- 61 | 62 | ## Contributing 63 | 64 | ### How to Contribute 65 | 66 | 1. Fork the repository. 67 | 2. Create a new branch for your changes: 68 | ```sh 69 | git checkout -b feature-name 70 | ``` 71 | 3. Make your changes and commit them: 72 | ```sh 73 | git commit -m "Describe your changes" 74 | ``` 75 | 4. Push your changes: 76 | ```sh 77 | git push origin feature-name 78 | ``` 79 | 5. Open a pull request. 80 | 81 | ### Adding a New Locale 82 | 83 | 1. Copy the `_locales/en/messages.json` file. 84 | 2. Rename it to your language code (e.g., `_locales/fr/messages.json`). 85 | 3. Translate the contents accordingly. 86 | 4. Submit a pull request with the new locale. 87 | 88 | --- 89 | 90 | ## Credits 91 | 92 | - Original project: [Save-Image-as-Type](https://github.com/image4tools/Save-Image-as-Type) 93 | - Maintained and ported to Firefox by [@d7om](https://github.com/d7om) 94 | 95 | --- 96 | 97 | For any issues or feature requests, please open an issue on [GitHub](https://github.com/d7om/Save-Image-as-Type/issues). 98 | -------------------------------------------------------------------------------- /src/offscreen/offscreen.js: -------------------------------------------------------------------------------- 1 | var workAsContent, contentPort, listened, handleMessages; 2 | 3 | if (!listened) { 4 | init(); 5 | listened = true; 6 | } 7 | 8 | function init() { 9 | handleMessages = async (message) => { 10 | let { op, target, filename, src, type } = message; 11 | if (target !== "offscreen" && target !== "content") { 12 | return false; 13 | } 14 | if (contentPort) { 15 | contentPort.disconnect(); 16 | contentPort = null; 17 | } 18 | if (!src || !src.startsWith("data:")) { 19 | notify("Unexpected src"); 20 | return false; 21 | } 22 | switch (op) { 23 | case "convertType": 24 | convertImageAsType(src, filename, type); 25 | break; 26 | case "download": 27 | if (!workAsContent) { 28 | notify("Cannot download on offscreen"); 29 | return false; 30 | } 31 | download(src, filename); 32 | break; 33 | default: 34 | console.warn(`Unexpected message type received: '${op}'.`); 35 | return false; 36 | } 37 | }; 38 | 39 | browser.runtime.onMessage.addListener(handleMessages); 40 | 41 | browser.runtime.onConnect.addListener((port) => { 42 | if (port.name == "convertType") { 43 | workAsContent = true; 44 | contentPort = port; 45 | port.onMessage.addListener(handleMessages); 46 | } 47 | }); 48 | } 49 | 50 | // Send notification to background script or show alert in content script 51 | function notify(message) { 52 | if (workAsContent) { 53 | alert(message); 54 | } else { 55 | browser.runtime.sendMessage({ 56 | op: "notify", 57 | target: "background", 58 | message, 59 | }); 60 | } 61 | } 62 | 63 | // Handle the download process 64 | function download(url, filename) { 65 | if (workAsContent) { 66 | let a = document.createElement("a"); 67 | a.href = url; 68 | a.download = filename; 69 | a.style.display = "none"; 70 | document.body.appendChild(a); 71 | a.click(); 72 | setTimeout(() => { 73 | document.body.removeChild(a); 74 | if (url.startsWith("blob:")) { 75 | URL.revokeObjectURL(url); // Clean up blob URLs only 76 | } 77 | }, 100); 78 | } else { 79 | browser.runtime.sendMessage({ 80 | op: "download", 81 | target: "background", 82 | url, 83 | filename, 84 | }); 85 | } 86 | } 87 | 88 | // Convert the image to the requested type 89 | function convertImageAsType(src, filename, type) { 90 | function getDataURLOfType(img, type) { 91 | var canvas = document.createElement("canvas"); 92 | canvas.width = img.width; 93 | canvas.height = img.height; 94 | var context = canvas.getContext("2d"); 95 | var mimeType = "image/" + (type == "jpg" ? "jpeg" : type); 96 | context.drawImage(img, 0, 0); 97 | var dataurl = canvas.toDataURL(mimeType); 98 | canvas = null; 99 | return dataurl; 100 | } 101 | 102 | function imageLoad(src, type, callback) { 103 | var img = new Image(); 104 | img.onload = function () { 105 | var dataurl = getDataURLOfType(this, type); 106 | callback(dataurl); 107 | }; 108 | img.onerror = function () { 109 | notify({ error: "errorOnLoading", src }); 110 | }; 111 | img.src = src; 112 | } 113 | 114 | function callback(dataurl) { 115 | download(dataurl, filename); 116 | } 117 | 118 | if (!src.startsWith("data:")) { 119 | // This shouldn't happen as we validate in handleMessages 120 | } else { 121 | imageLoad(src, type, callback); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/options/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Save Image As Type Options 6 | 154 | 155 | 156 | 157 | Save Image As Type Options 158 | 159 | 160 | Default Image Type: 161 | 162 | No default (show submenu) 163 | JPG 164 | PNG 165 | WebP 166 | 167 | Select "No default" to show all format options in a submenu 168 | 169 | 170 | 171 | 172 | 173 | Show "View in Store" button 174 | 175 | Toggle the visibility of the "View in Store" button in the context 177 | menu 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | Save Options 194 | Reset to Defaults 195 | 196 | 197 | Options saved! 198 | 199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | let messages; 2 | let userPreferences = { 3 | defaultType: "", 4 | defaultLocation: "", 5 | showStoreButton: false, 6 | }; 7 | 8 | if (!browser.i18n?.getMessage) { 9 | browser.i18n = browser.i18n || {}; 10 | browser.i18n.getMessage = (key, args) => { 11 | const messages = { 12 | View_in_store: "View in store", 13 | Save_as: args?.[0] ? `Save as ${args[0]}` : key, 14 | Save_image_as: "Save image as type >", 15 | Options: "Options", 16 | errorOnSaving: "Error on saving", 17 | errorIsNotImage: "Selected item is not an image", 18 | errorOnLoading: "Error loading image", 19 | }; 20 | return messages[key] || key; 21 | }; 22 | } 23 | 24 | function loadUserPreferences() { 25 | return browser.storage.sync 26 | .get({ defaultType: "", showStoreButton: false }) 27 | .then((items) => { 28 | userPreferences = items; 29 | updateContextMenus(); 30 | }); 31 | } 32 | 33 | function updateContextMenus() { 34 | browser.contextMenus.removeAll().then(() => { 35 | if (userPreferences.defaultType) { 36 | const defaultTypeUpper = userPreferences.defaultType.toUpperCase(); 37 | browser.contextMenus.create({ 38 | id: "save_as_default", 39 | title: browser.i18n.getMessage("Save_as", [defaultTypeUpper]), 40 | contexts: ["image"], 41 | type: "normal", 42 | }); 43 | } else { 44 | const parentId = browser.contextMenus.create({ 45 | id: "save_image_as", 46 | title: browser.i18n.getMessage("Save_image_as"), 47 | contexts: ["image"], 48 | type: "normal", 49 | }); 50 | 51 | ["JPG", "PNG", "WebP"].forEach((type) => { 52 | browser.contextMenus.create({ 53 | id: `save_as_${type.toLowerCase()}`, 54 | parentId: parentId, 55 | title: type.toUpperCase(), 56 | contexts: ["image"], 57 | type: "normal", 58 | }); 59 | }); 60 | 61 | browser.contextMenus.create({ 62 | id: "sep_1", 63 | type: "separator", 64 | parentId: parentId, 65 | contexts: ["image"], 66 | }); 67 | 68 | browser.contextMenus.create({ 69 | id: "open_options", 70 | parentId: parentId, 71 | title: browser.i18n.getMessage("Options"), 72 | contexts: ["image"], 73 | type: "normal", 74 | }); 75 | 76 | if (userPreferences.showStoreButton) { 77 | browser.contextMenus.create({ 78 | id: "view_in_store", 79 | parentId: parentId, 80 | title: browser.i18n.getMessage("View_in_store"), 81 | contexts: ["image"], 82 | type: "normal", 83 | }); 84 | } 85 | } 86 | }); 87 | } 88 | 89 | // Helper: Convert a data URL to a Blob 90 | function dataURLtoBlob(dataurl) { 91 | const arr = dataurl.split(","); 92 | const mimeMatch = arr[0].match(/:(.*?);/); 93 | if (!mimeMatch) { 94 | throw new Error("Invalid data URL"); 95 | } 96 | const mime = mimeMatch[1]; 97 | const bstr = atob(arr[1]); 98 | let n = bstr.length; 99 | const u8arr = new Uint8Array(n); 100 | while (n--) { 101 | u8arr[n] = bstr.charCodeAt(n); 102 | } 103 | return new Blob([u8arr], { type: mime }); 104 | } 105 | 106 | function download(url, filename) { 107 | if (url.startsWith("data:")) { 108 | try { 109 | const blob = dataURLtoBlob(url); 110 | const blobUrl = URL.createObjectURL(blob); 111 | browser.downloads.download( 112 | { url: blobUrl, filename, saveAs: true }, 113 | (downloadId) => { 114 | if (!downloadId) { 115 | let msg = browser.i18n.getMessage("errorOnSaving"); 116 | if (browser.runtime.lastError) { 117 | msg += `: \n${browser.runtime.lastError.message}`; 118 | } 119 | notify(msg); 120 | } 121 | // Clean up blob URL after download 122 | setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); 123 | }, 124 | ); 125 | } catch (error) { 126 | notify(error); 127 | } 128 | } else { 129 | browser.downloads.download( 130 | { url, filename, saveAs: true }, 131 | (downloadId) => { 132 | if (!downloadId) { 133 | let msg = browser.i18n.getMessage("errorOnSaving"); 134 | if (browser.runtime.lastError) { 135 | msg += `: \n${browser.runtime.lastError.message}`; 136 | } 137 | notify(msg); 138 | } 139 | }, 140 | ); 141 | } 142 | } 143 | 144 | async function fetchAsDataURL(src, callback) { 145 | if (src.startsWith("data:")) { 146 | callback(null, src); 147 | return; 148 | } 149 | try { 150 | const res = await fetch(src); 151 | const blob = await res.blob(); 152 | if (!blob.size) throw "Fetch failed of 0 size"; 153 | const reader = new FileReader(); 154 | reader.onload = (evt) => callback(null, evt.target.result); 155 | reader.readAsDataURL(blob); 156 | } catch (error) { 157 | console.error("Fetch error:", error); 158 | callback(null, src); 159 | } 160 | } 161 | 162 | function getSuggestedFilename(src, type) { 163 | if (/googleusercontent\.com\/[0-9a-zA-Z]{30,}/.test(src)) 164 | return `screenshot.${type}`; 165 | if (src.startsWith("blob:") || src.startsWith("data:")) 166 | return `Untitled.${type}`; 167 | let filename = decodeURIComponent( 168 | src 169 | .replace(/[?#].*/, "") 170 | .split("/") 171 | .pop() 172 | .replace(/\+/g, " "), 173 | ); 174 | filename = filename 175 | .replace(/[^\w\-\.\,@ ]+/g, "") 176 | .replace(/\s\s+/g, " ") 177 | .trim(); 178 | filename = filename.replace(/\.(jpe?g|png|gif|webp|svg)$/gi, "").trim(); 179 | if (filename.length > 32) filename = filename.substring(0, 32); 180 | filename = filename.replace(/[^0-9a-z]+$/i, "").trim(); 181 | return (filename || "image") + `.${type}`; 182 | } 183 | 184 | function notify(msg) { 185 | if (msg.error) { 186 | msg = `${browser.i18n.getMessage(msg.error) || msg.error}\n${msg.srcUrl || msg.src}`; 187 | } 188 | console.error(msg); 189 | } 190 | 191 | function loadMessages() { 192 | if (!messages) { 193 | messages = {}; 194 | ["errorOnSaving", "errorOnLoading", "errorIsNotImage"].forEach((key) => { 195 | messages[key] = browser.i18n.getMessage(key); 196 | }); 197 | } 198 | return messages; 199 | } 200 | 201 | async function hasOffscreenDocument(path) { 202 | try { 203 | const offscreenUrl = browser.runtime.getURL(path); 204 | const matchedClients = await clients.matchAll(); 205 | return matchedClients.some((client) => client.url === offscreenUrl); 206 | } catch (err) { 207 | return false; 208 | } 209 | } 210 | 211 | // Helper: Connect to tab for fallback messaging 212 | function connectTab(tab, frameId) { 213 | return browser.tabs.connect(tab.id, { name: "convertType", frameId }); 214 | } 215 | 216 | async function processImageSave(srcUrl, type, tab, info) { 217 | const filename = getSuggestedFilename(srcUrl, type); 218 | loadMessages(); 219 | // Determine if no conversion is needed (already in the desired format) 220 | const noChange = srcUrl.startsWith( 221 | `data:image/${type === "jpg" ? "jpeg" : type};`, 222 | ); 223 | 224 | try { 225 | fetchAsDataURL(srcUrl, async (error, dataurl) => { 226 | if (error) { 227 | notify({ error, srcUrl }); 228 | return; 229 | } 230 | // If we didn't get a converted data URL (e.g. due to CORS), dataurl will be the original URL 231 | if (noChange || dataurl === srcUrl) { 232 | download(dataurl, filename); 233 | return; 234 | } 235 | 236 | // Use Offscreen API if available 237 | if (browser.offscreen && browser.offscreen.createDocument) { 238 | const offscreenSrc = "src/offscreen/offscreen.html"; 239 | if (!(await hasOffscreenDocument(offscreenSrc))) { 240 | await browser.offscreen.createDocument({ 241 | url: browser.runtime.getURL(offscreenSrc), 242 | reasons: ["DOM_SCRAPING"], 243 | justification: "Download an image for user", 244 | }); 245 | } 246 | await browser.runtime.sendMessage({ 247 | op: "convertType", 248 | target: "offscreen", 249 | src: dataurl, 250 | type, 251 | filename, 252 | }); 253 | } else { 254 | // Fallback to content script approach via port 255 | const frameIds = info.frameId ? [info.frameId] : undefined; 256 | await browser.scripting.executeScript({ 257 | target: { tabId: tab.id, frameIds }, 258 | files: ["src/offscreen/offscreen.js"], 259 | }); 260 | const port = connectTab(tab, info.frameId); 261 | port.postMessage({ 262 | op: "convertType", 263 | target: "content", 264 | src: dataurl, 265 | type, 266 | filename, 267 | }); 268 | } 269 | }); 270 | } catch (error) { 271 | notify({ error: error.message || "Unknown error", srcUrl }); 272 | } 273 | } 274 | 275 | // 276 | // Event Listeners 277 | // 278 | 279 | browser.runtime.onInstalled.addListener(() => { 280 | loadMessages(); 281 | loadUserPreferences(); 282 | }); 283 | 284 | browser.storage.onChanged.addListener((changes, area) => { 285 | if (area === "sync") { 286 | if (changes.defaultType) { 287 | userPreferences.defaultType = changes.defaultType.newValue; 288 | } 289 | if (changes.showStoreButton) { 290 | userPreferences.showStoreButton = changes.showStoreButton.newValue; 291 | } 292 | updateContextMenus(); 293 | } 294 | }); 295 | 296 | browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 297 | const { target, op } = message || {}; 298 | if (target === "background" && op) { 299 | if (op === "download") { 300 | const { url, filename } = message; 301 | download(url, filename); 302 | } else if (op === "notify") { 303 | const msg = message.message; 304 | if (msg && msg.error) { 305 | let msg2 = browser.i18n.getMessage(msg.error) || msg.error; 306 | if (msg.src) msg2 += `\n${msg.src}`; 307 | notify(msg2); 308 | } else { 309 | notify(message); 310 | } 311 | } else { 312 | console.warn(`unknown op: ${op}`); 313 | } 314 | } 315 | }); 316 | 317 | browser.contextMenus.onClicked.addListener(async (info, tab) => { 318 | const { menuItemId, mediaType, srcUrl } = info; 319 | if (menuItemId === "open_options") { 320 | browser.runtime.openOptionsPage(); 321 | } else if (menuItemId === "save_as_default") { 322 | if (mediaType === "image" && srcUrl) { 323 | processImageSave(srcUrl, userPreferences.defaultType, tab, info); 324 | } else { 325 | notify(browser.i18n.getMessage("errorIsNotImage")); 326 | } 327 | } else if (menuItemId.startsWith("save_as_")) { 328 | if (mediaType === "image" && srcUrl) { 329 | const type = menuItemId.replace("save_as_", ""); 330 | processImageSave(srcUrl, type, tab, info); 331 | } else { 332 | notify(browser.i18n.getMessage("errorIsNotImage")); 333 | } 334 | } else if (menuItemId === "view_in_store") { 335 | const url = `https://addons.mozilla.org/firefox/addon/siat/`; 336 | browser.tabs.create({ url, index: tab.index + 1 }); 337 | } 338 | }); 339 | --------------------------------------------------------------------------------