diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f0a1af2d..7d45ba67 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2314,5 +2314,13 @@ "menu_order_hidden_items": { "message": "Hidden items", "description": "Section header for hidden menu items" + }, + "menu_order_icon_label": { + "message": "Choose an icon", + "description": "Label/tooltip for the context menu icon picker on the menu order page" + }, + "menu_order_icon_none": { + "message": "(none)", + "description": "Dropdown option meaning no custom icon is selected for the context menu item" } } \ No newline at end of file diff --git a/claude-spec/02-prompts.md b/claude-spec/02-prompts.md index ae33e52e..d2268831 100644 --- a/claude-spec/02-prompts.md +++ b/claude-spec/02-prompts.md @@ -33,6 +33,7 @@ Prompts are the core user-facing feature of ThunderAI. Each prompt defines an AI | `position_compose` | number | Sort order for the popup menu in compose view | | `position_context` | number | Sort order for the context menu | | `show_in` | string | `"popup"` = popup only, `"context"` = context menu only, `"both"` = both, `"none"` = hidden from all menus. Default: `"popup"` for default/custom prompts, `"both"` for special prompts | +| `custom_icon` | string | Filename (with extension) of an icon in `images/custom_menu/` used as the context-menu icon. Empty string = no icon. Only used for non-special prompts (special prompts use their hard-coded icons in `specialPromptToContextMenuID`). Selectable from a dropdown on the Menu Order page, context-menu tab. | ### Per-Prompt API Override Properties diff --git a/images/custom_menu/empty_icon.png b/images/custom_menu/empty_icon.png new file mode 100644 index 00000000..e7ebda09 Binary files /dev/null and b/images/custom_menu/empty_icon.png differ diff --git a/js/mzta-menus.js b/js/mzta-menus.js index b7230250..6fd63209 100644 --- a/js/mzta-menus.js +++ b/js/mzta-menus.js @@ -528,14 +528,18 @@ export class mzta_Menus { // Create child menu items for (const prompt of contextPrompts) { const title = i18nConditionalGet(prompt.name); + const iconPath = getContextMenuIcon(prompt); + const menuOpts = { + id: 'mzta-ctx-' + prompt.id, + title: title, + contexts: ["message_list"], + parentId: 'mzta-context-parent', + }; + if (iconPath) { + menuOpts.icons = iconPath; + } await new Promise(resolve => - browser.menus.create({ - id: 'mzta-ctx-' + prompt.id, - title: title, - contexts: ["message_list"], - parentId: 'mzta-context-parent', - icons: getContextMenuIcon(prompt.id), - }, resolve) + browser.menus.create(menuOpts, resolve) ); } this.logger.log("Context menus loaded: " + contextPrompts.length + " items"); diff --git a/js/mzta-prompts.js b/js/mzta-prompts.js index 13c76b83..f81f9803 100644 --- a/js/mzta-prompts.js +++ b/js/mzta-prompts.js @@ -561,6 +561,7 @@ async function getDefaultPrompts_withProps() { prompt.chatgpt_web_custom_gpt = (prefs._default_prompts_properties[prompt.id]?.chatgpt_web_custom_gpt || '').trim(); prompt.api_type = (prefs._default_prompts_properties[prompt.id]?.api_type || '').trim(); prompt.show_in = prefs._default_prompts_properties[prompt.id]?.show_in || prompt.show_in; + prompt.custom_icon = prefs._default_prompts_properties[prompt.id]?.custom_icon || ""; }else{ prompt.position_display = pos; prompt.position_compose = pos; @@ -600,6 +601,9 @@ async function getCustomPrompts() { if(prompt.show_in === undefined){ prompt.show_in = "popup"; } + if(prompt.custom_icon === undefined){ + prompt.custom_icon = ""; + } }); return prefs._custom_prompt; } @@ -619,6 +623,7 @@ export async function setDefaultPromptsProperties(prompts) { chatgpt_web_custom_gpt: (prompt.chatgpt_web_custom_gpt === undefined || prompt.chatgpt_web_custom_gpt === "undefined") ? "" : prompt.chatgpt_web_custom_gpt, api_type: (prompt.api_type === undefined || prompt.api_type === "undefined") ? "" : prompt.api_type, show_in: (prompt.show_in === undefined || prompt.show_in === "undefined") ? "popup" : prompt.show_in, + custom_icon: (prompt.custom_icon === undefined || prompt.custom_icon === "undefined") ? "" : prompt.custom_icon, }; }); //console.log('>>>>>>>>>>>>>> default_prompts_properties: ' + JSON.stringify(default_prompts_properties)); diff --git a/js/mzta-utils.js b/js/mzta-utils.js index 4e80f6b3..ffa24ca3 100644 --- a/js/mzta-utils.js +++ b/js/mzta-utils.js @@ -46,11 +46,22 @@ export const specialPromptToContextMenuID = { // const defaultContextMenuIcon = 'moz-extension:images/icon-32px.png'; const defaultContextMenuIcon = ''; -export function getContextMenuIcon(promptId) { - const contextMenuId = specialPromptToContextMenuID[promptId]; - if (contextMenuId && contextMenuIconsPath[contextMenuId]) { - return contextMenuIconsPath[contextMenuId]; +export function getContextMenuIcon(prompt) { + // Back-compat: accept a plain id string too + const promptId = (typeof prompt === 'string') ? prompt : prompt?.id; + const isSpecial = (typeof prompt === 'object' && prompt !== null) ? String(prompt.is_special) === '1' : true; + + if (isSpecial) { + const contextMenuId = specialPromptToContextMenuID[promptId]; + if (contextMenuId && contextMenuIconsPath[contextMenuId]) { + return contextMenuIconsPath[contextMenuId]; + } } + + if (typeof prompt === 'object' && prompt !== null && prompt.custom_icon) { + return 'moz-extension:images/custom_menu/' + prompt.custom_icon; + } + return defaultContextMenuIcon; } diff --git a/pages/menu_order/mzta-custom-menu-icons.js b/pages/menu_order/mzta-custom-menu-icons.js new file mode 100644 index 00000000..df4ffba3 --- /dev/null +++ b/pages/menu_order/mzta-custom-menu-icons.js @@ -0,0 +1,41 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 - 2026 Mic (m@micz.it) + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export const customMenuIconsPath = 'images/custom_menu/'; + +export const customMenuIcons = [ + 'adventure-game.png', + 'calendar.png', + 'clapboard.png', + 'clock.png', + 'copywriting.png', + 'customer-service.png', + 'deadline.png', + 'express-delivery.png', + 'home.png', + 'info.png', + 'invoice.png', + 'justice-scale.png', + 'like.png', + 'love-letter.png', + 'policeman.png', + 'printer.png', + 'puzzle-game.png', + 'scissors.png', + 'send.png', +]; diff --git a/pages/menu_order/mzta-menu-order.css b/pages/menu_order/mzta-menu-order.css index b6afb6f1..81b5055f 100644 --- a/pages/menu_order/mzta-menu-order.css +++ b/pages/menu_order/mzta-menu-order.css @@ -171,6 +171,86 @@ color: #e65100; } +.item_icon_preview { + width: 18px; + height: 18px; + object-fit: contain; + flex-shrink: 0; + cursor: pointer; + border: 1px solid transparent; + border-radius: 3px; + padding: 1px; + box-sizing: border-box; +} + +.item_icon_preview:hover { + border-color: #409df3; + background: #eef5ff; +} + +.item_icon_preview_empty { + display: inline-block; + background: transparent; +} + +.item_icon_preview_special { + cursor: default; +} + +.item_icon_preview_special:hover { + border-color: transparent; + background: transparent; +} + +.icon_picker_popover { + position: absolute; + z-index: 1000; + background: #fff; + border: 1px solid #bbb; + border-radius: 6px; + padding: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + display: grid; + grid-template-columns: repeat(5, 28px); + gap: 4px; + max-width: 180px; +} + +.icon_picker_cell { + width: 28px; + height: 28px; + padding: 2px; + border: 1px solid transparent; + background: transparent; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.icon_picker_cell img { + width: 20px; + height: 20px; + object-fit: contain; +} + +.icon_picker_cell:hover { + background: #eef5ff; + border-color: #409df3; +} + +.icon_picker_cell.selected { + background: #d4eaff; + border-color: #409df3; +} + +.icon_picker_cell_none { + font-size: 1.2em; + color: #888; +} + /* Hidden list items */ .hidden_list .sortable_item { opacity: 0.6; @@ -268,6 +348,29 @@ color: #ffb74d; } + .item_icon_preview:hover { + background: #3a3a44; + border-color: #409df3; + } + + .icon_picker_popover { + background: #2a2a30; + border-color: #555; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); + } + + .icon_picker_cell:hover { + background: #3a3a44; + } + + .icon_picker_cell.selected { + background: #1a3a5c; + } + + .icon_picker_cell_none { + color: #aaa; + } + .hidden_list .sortable_item { background: #2a2a2e; } diff --git a/pages/menu_order/mzta-menu-order.js b/pages/menu_order/mzta-menu-order.js index 93ab37c1..f2551e54 100644 --- a/pages/menu_order/mzta-menu-order.js +++ b/pages/menu_order/mzta-menu-order.js @@ -23,7 +23,17 @@ import { setSpecialPrompts, getHiddenSpecialPromptIds } from '../../js/mzta-prompts.js'; -import { i18nConditionalGet } from '../../js/mzta-utils.js'; +import { i18nConditionalGet, specialPromptToContextMenuID, contextMenuIconsPath } from '../../js/mzta-utils.js'; +import { customMenuIcons, customMenuIconsPath } from './mzta-custom-menu-icons.js'; + +// Convert "moz-extension:images/foo.png" to a relative path usable from this page +function resolveSpecialIconPath(promptId) { + const ctxId = specialPromptToContextMenuID[promptId]; + if (!ctxId) return ''; + const raw = contextMenuIconsPath[ctxId]; + if (!raw) return ''; + return '../../' + raw.replace(/^moz-extension:/, ''); +} let allPrompts = []; let allExcludedSpecialPrompts = []; // special prompts excluded from UI (hidden + inactive features), preserved on save @@ -182,6 +192,15 @@ function renderListItems(listEl, items, menuType, isActive) { handle.textContent = '\u2630'; li.appendChild(handle); + // Icon slot (context menu only) - between handle and toggle, to keep rows aligned + if (menuType === 'context') { + if (String(prompt.is_special) === '1') { + li.appendChild(buildSpecialIconDisplay(prompt)); + } else { + li.appendChild(buildIconPicker(prompt)); + } + } + // Toggle checkbox const toggle = document.createElement('input'); toggle.type = 'checkbox'; @@ -229,6 +248,136 @@ function renderListItems(listEl, items, menuType, isActive) { }); } +// ==================== Custom icon picker ==================== + +let activeIconPopover = null; + +function closeIconPopover() { + if (activeIconPopover) { + activeIconPopover.remove(); + activeIconPopover = null; + document.removeEventListener('mousedown', onDocMouseDownForPopover, true); + document.removeEventListener('keydown', onDocKeyDownForPopover, true); + } +} + +function onDocMouseDownForPopover(e) { + if (activeIconPopover && !activeIconPopover.contains(e.target) && !e.target.classList.contains('item_icon_preview')) { + closeIconPopover(); + } +} + +function onDocKeyDownForPopover(e) { + if (e.key === 'Escape') closeIconPopover(); +} + +function applyIconToPreview(preview, filename) { + if (filename) { + preview.src = '../../' + customMenuIconsPath + filename; + preview.classList.remove('item_icon_preview_empty'); + } else { + preview.src = '../../' + customMenuIconsPath + 'empty_icon.png'; + preview.classList.add('item_icon_preview_empty'); + } +} + +function buildSpecialIconDisplay(prompt) { + const img = document.createElement('img'); + img.classList.add('item_icon_preview', 'item_icon_preview_special'); + img.alt = ''; + const path = resolveSpecialIconPath(prompt.id); + if (path) { + img.src = path; + } else { + img.src = '../../' + customMenuIconsPath + 'empty_icon.png'; + img.classList.add('item_icon_preview_empty'); + } + return img; +} + +function buildIconPicker(prompt) { + const preview = document.createElement('img'); + preview.classList.add('item_icon_preview'); + preview.alt = ''; + preview.title = browser.i18n.getMessage('menu_order_icon_label'); + applyIconToPreview(preview, prompt.custom_icon || ''); + + preview.addEventListener('click', (e) => { + e.stopPropagation(); + if (activeIconPopover && activeIconPopover.dataset.forId === prompt.id) { + closeIconPopover(); + return; + } + closeIconPopover(); + openIconPopover(preview, prompt); + }); + + return preview; +} + +function openIconPopover(anchorEl, prompt) { + const popover = document.createElement('div'); + popover.classList.add('icon_picker_popover'); + popover.dataset.forId = prompt.id; + + // "None" option + const noneBtn = document.createElement('button'); + noneBtn.type = 'button'; + noneBtn.classList.add('icon_picker_cell', 'icon_picker_cell_none'); + noneBtn.title = browser.i18n.getMessage('menu_order_icon_none'); + const noneImg = document.createElement('img'); + noneImg.src = '../../' + customMenuIconsPath + 'empty_icon.png'; + noneImg.alt = ''; + noneBtn.appendChild(noneImg); + if (!prompt.custom_icon) noneBtn.classList.add('selected'); + noneBtn.addEventListener('click', () => { + prompt.custom_icon = ''; + applyIconToPreview(anchorEl, ''); + markUnsaved(); + closeIconPopover(); + }); + popover.appendChild(noneBtn); + + customMenuIcons.forEach(filename => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.classList.add('icon_picker_cell'); + btn.title = filename.replace(/\.[^.]+$/, ''); + if (filename === prompt.custom_icon) btn.classList.add('selected'); + + const img = document.createElement('img'); + img.src = '../../' + customMenuIconsPath + filename; + img.alt = ''; + btn.appendChild(img); + + btn.addEventListener('click', () => { + prompt.custom_icon = filename; + applyIconToPreview(anchorEl, filename); + markUnsaved(); + closeIconPopover(); + }); + popover.appendChild(btn); + }); + + document.body.appendChild(popover); + activeIconPopover = popover; + + // Position popover below the anchor + const rect = anchorEl.getBoundingClientRect(); + const popRect = popover.getBoundingClientRect(); + let left = rect.left + window.scrollX; + let top = rect.bottom + window.scrollY + 4; + if (left + popRect.width > window.scrollX + document.documentElement.clientWidth - 8) { + left = window.scrollX + document.documentElement.clientWidth - popRect.width - 8; + } + if (left < window.scrollX + 4) left = window.scrollX + 4; + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + + document.addEventListener('mousedown', onDocMouseDownForPopover, true); + document.addEventListener('keydown', onDocKeyDownForPopover, true); +} + // ==================== Toggle show_in ==================== function toggleShowIn(prompt, menuType, isOn) {