diff --git a/packages/extension/public/_locales/de/messages.json b/packages/extension/public/_locales/de/messages.json index a21d8a1d..10475ea9 100644 --- a/packages/extension/public/_locales/de/messages.json +++ b/packages/extension/public/_locales/de/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Zeigt die verbleibende Zeit bis zur Vorschau an." }, + "Option_windowSettings": { + "message": "Fenstereinstellungen" + }, + "Option_windowSettings_desc": { + "message": "Konfigurieren Sie das Fensterverhalten und Popup-Einstellungen." + }, + "Option_popupAutoCloseDelay": { + "message": "Popup-Autoschluss-Verzögerung" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Stellen Sie die Verzögerungszeit ein, bevor das Popup nach dem Verlust des Fokus automatisch geschlossen wird. Stellen Sie 0 ein oder lassen Sie es leer für sofortiges Schließen.\nMaximum: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (sofort schließen)" + }, "Option_folders": { "message": "Ordner" }, diff --git a/packages/extension/public/_locales/en/messages.json b/packages/extension/public/_locales/en/messages.json index 1ac8502a..fc3f0541 100644 --- a/packages/extension/public/_locales/en/messages.json +++ b/packages/extension/public/_locales/en/messages.json @@ -203,6 +203,15 @@ "Option_popupAnimation": { "message": "Menu Display Animation" }, + "Option_popupAutoCloseDelay": { + "message": "Popup Auto-Close Delay" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Set the delay time before the popup automatically closes after losing focus. Set to 0 or leave empty for immediate close.\nMaximum: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (close immediately)" + }, "Option_inherit": { "message": "Inherit" }, @@ -503,6 +512,12 @@ "Option_showIndicator_desc": { "message": "Displays the remaining to the preview." }, + "Option_windowSettings": { + "message": "Window Settings" + }, + "Option_windowSettings_desc": { + "message": "Configure window behavior and popup settings." + }, "Option_folders": { "message": "Folders" }, diff --git a/packages/extension/public/_locales/es/messages.json b/packages/extension/public/_locales/es/messages.json index fe25ac49..003912d3 100644 --- a/packages/extension/public/_locales/es/messages.json +++ b/packages/extension/public/_locales/es/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Muestra el tiempo restante hasta la vista previa." }, + "Option_windowSettings": { + "message": "Configuración de Ventana" + }, + "Option_windowSettings_desc": { + "message": "Configure el comportamiento de la ventana y la configuración de ventanas emergentes." + }, + "Option_popupAutoCloseDelay": { + "message": "Retardo de Cierre Automático de Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Establezca el tiempo de retardo antes de que el popup se cierre automáticamente después de perder el foco. Establezca 0 o déjelo vacío para cierre inmediato.\nMáximo: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (cerrar inmediatamente)" + }, "Option_folders": { "message": "Carpetas" }, diff --git a/packages/extension/public/_locales/fr/messages.json b/packages/extension/public/_locales/fr/messages.json index 877041df..50133e3e 100644 --- a/packages/extension/public/_locales/fr/messages.json +++ b/packages/extension/public/_locales/fr/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Affiche le temps restant jusqu'à l'aperçu." }, + "Option_windowSettings": { + "message": "Paramètres de Fenêtre" + }, + "Option_windowSettings_desc": { + "message": "Configurez le comportement de la fenêtre et les paramètres de popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Délai de Fermeture Automatique du Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Définissez le délai avant que le popup se ferme automatiquement après avoir perdu le focus. Définissez 0 ou laissez vide pour une fermeture immédiate.\nMaximum: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (fermer immédiatement)" + }, "Option_folders": { "message": "Dossiers" }, diff --git a/packages/extension/public/_locales/hi/messages.json b/packages/extension/public/_locales/hi/messages.json index 53333a7e..1e4640d3 100644 --- a/packages/extension/public/_locales/hi/messages.json +++ b/packages/extension/public/_locales/hi/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "प्रीव्यू तक शेष समय दिखाएं।" }, + "Option_windowSettings": { + "message": "विंडो सेटिंग्स" + }, + "Option_windowSettings_desc": { + "message": "विंडो व्यवहार और पॉपअप सेटिंग्स को कॉन्फ़िगर करें।" + }, + "Option_popupAutoCloseDelay": { + "message": "पॉपअप ऑटो-क्लोज़ विलंब" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "फोकस खोने के बाद पॉपअप के स्वचालित रूप से बंद होने से पहले विलंब का समय सेट करें। तुरंत बंद करने के लिए 0 सेट करें या खाली छोड़ दें।\nअधिकतम: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (तुरंत बंद करें)" + }, "Option_folders": { "message": "फ़ोल्डर्स" }, diff --git a/packages/extension/public/_locales/id/messages.json b/packages/extension/public/_locales/id/messages.json index 2f5e66dd..b3685a0f 100644 --- a/packages/extension/public/_locales/id/messages.json +++ b/packages/extension/public/_locales/id/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Menampilkan sisa ke pratinjau." }, + "Option_windowSettings": { + "message": "Pengaturan Jendela" + }, + "Option_windowSettings_desc": { + "message": "Konfigurasi perilaku jendela dan pengaturan popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Penundaan Penutupan Otomatis Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Atur waktu penundaan sebelum popup secara otomatis menutup setelah kehilangan fokus. Atur ke 0 atau biarkan kosong untuk penutupan segera.\nMaksimum: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (tutup segera)" + }, "Option_folders": { "message": "Folder" }, diff --git a/packages/extension/public/_locales/it/messages.json b/packages/extension/public/_locales/it/messages.json index 2f72f722..97b4c3e6 100644 --- a/packages/extension/public/_locales/it/messages.json +++ b/packages/extension/public/_locales/it/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Mostra il tempo rimanente fino all'anteprima." }, + "Option_windowSettings": { + "message": "Impostazioni Finestra" + }, + "Option_windowSettings_desc": { + "message": "Configura il comportamento della finestra e le impostazioni popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Ritardo Chiusura Automatica Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Imposta il tempo di ritardo prima che il popup si chiuda automaticamente dopo aver perso il focus. Imposta 0 o lascia vuoto per la chiusura immediata.\nMassimo: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (chiudi immediatamente)" + }, "Option_folders": { "message": "Cartelle" }, diff --git a/packages/extension/public/_locales/ja/messages.json b/packages/extension/public/_locales/ja/messages.json index 905fef14..69b3350f 100644 --- a/packages/extension/public/_locales/ja/messages.json +++ b/packages/extension/public/_locales/ja/messages.json @@ -203,6 +203,15 @@ "Option_popupAnimation": { "message": "メニュー表示アニメーション" }, + "Option_popupAutoCloseDelay": { + "message": "ポップアップ自動クローズまでの時間" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "ポップアップウィンドウのフォーカスが外れてから自動的に閉じるまでの時間を設定します。0または未設定の場合は即座に閉じます。 \n最大:10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (即座に閉じる)" + }, "Option_inherit": { "message": "継承" }, @@ -590,6 +599,12 @@ "Option_showIndicator_desc": { "message": "プレビューまでの残りを表示します。" }, + "Option_windowSettings": { + "message": "ウィンドウ設定" + }, + "Option_windowSettings_desc": { + "message": "ウィンドウやポップアップの設定を行います。" + }, "Option_folders": { "message": "フォルダ" }, diff --git a/packages/extension/public/_locales/ko/messages.json b/packages/extension/public/_locales/ko/messages.json index f680f23f..3dd5dea4 100644 --- a/packages/extension/public/_locales/ko/messages.json +++ b/packages/extension/public/_locales/ko/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "미리보기까지 남은 시간을 표시합니다." }, + "Option_windowSettings": { + "message": "창 설정" + }, + "Option_windowSettings_desc": { + "message": "창 동작 및 팝업 설정을 구성합니다." + }, + "Option_popupAutoCloseDelay": { + "message": "팝업 자동 닫기 지연" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "포커스를 잃은 후 팝업이 자동으로 닫히기 전 지연 시간을 설정합니다. 즉시 닫으려면 0으로 설정하거나 비워 두십시오.\n최대: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (즉시 닫기)" + }, "Option_folders": { "message": "폴더" }, diff --git a/packages/extension/public/_locales/ms/messages.json b/packages/extension/public/_locales/ms/messages.json index 01a3d6ec..3fc0f4b9 100644 --- a/packages/extension/public/_locales/ms/messages.json +++ b/packages/extension/public/_locales/ms/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Memaparkan baki ke pratonton." }, + "Option_windowSettings": { + "message": "Tetapan Tetingkap" + }, + "Option_windowSettings_desc": { + "message": "Konfigurasi tingkah laku tetingkap dan tetapan popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Kelewatan Penutupan Auto Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Tetapkan masa kelewatan sebelum popup menutup secara automatik selepas kehilangan fokus. Tetapkan ke 0 atau biarkan kosong untuk penutupan segera.\nMaksimum: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (tutup segera)" + }, "Option_folders": { "message": "Folder" }, diff --git a/packages/extension/public/_locales/pt_BR/messages.json b/packages/extension/public/_locales/pt_BR/messages.json index 60d3069b..54b60b85 100644 --- a/packages/extension/public/_locales/pt_BR/messages.json +++ b/packages/extension/public/_locales/pt_BR/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Exibe o restante para a visualização." }, + "Option_windowSettings": { + "message": "Configurações da Janela" + }, + "Option_windowSettings_desc": { + "message": "Configure o comportamento da janela e as configurações de popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Atraso de Fechamento Automático do Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Defina o tempo de atraso antes que o popup feche automaticamente após perder o foco. Defina como 0 ou deixe vazio para fechamento imediato.\nMáximo: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (fechar imediatamente)" + }, "Option_folders": { "message": "Pastas" }, diff --git a/packages/extension/public/_locales/pt_PT/messages.json b/packages/extension/public/_locales/pt_PT/messages.json index 90f83ec5..5a5796b7 100644 --- a/packages/extension/public/_locales/pt_PT/messages.json +++ b/packages/extension/public/_locales/pt_PT/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Exibe o restante para a visualização." }, + "Option_windowSettings": { + "message": "Definições da Janela" + }, + "Option_windowSettings_desc": { + "message": "Configure o comportamento da janela e as definições de popup." + }, + "Option_popupAutoCloseDelay": { + "message": "Atraso de Fecho Automático do Popup" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Defina o tempo de atraso antes que o popup feche automaticamente após perder o foco. Defina como 0 ou deixe vazio para fecho imediato.\nMáximo: 10000 ms" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (fechar imediatamente)" + }, "Option_folders": { "message": "Pastas" }, diff --git a/packages/extension/public/_locales/ru/messages.json b/packages/extension/public/_locales/ru/messages.json index 4e8b3a8e..ffc12c76 100644 --- a/packages/extension/public/_locales/ru/messages.json +++ b/packages/extension/public/_locales/ru/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "Показывать оставшееся время до предпросмотра." }, + "Option_windowSettings": { + "message": "Настройки Окна" + }, + "Option_windowSettings_desc": { + "message": "Настройте поведение окна и параметры всплывающих окон." + }, + "Option_popupAutoCloseDelay": { + "message": "Задержка Автозакрытия Всплывающего Окна" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "Установите время задержки перед автоматическим закрытием всплывающего окна после потери фокуса. Установите 0 или оставьте пустым для немедленного закрытия.\nМаксимум: 10000 мс" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0 (закрыть немедленно)" + }, "Option_folders": { "message": "Папки" }, diff --git a/packages/extension/public/_locales/zh_CN/messages.json b/packages/extension/public/_locales/zh_CN/messages.json index 89dea19d..6a8c2d1f 100644 --- a/packages/extension/public/_locales/zh_CN/messages.json +++ b/packages/extension/public/_locales/zh_CN/messages.json @@ -461,6 +461,21 @@ "Option_showIndicator_desc": { "message": "显示预览前的剩余时间。" }, + "Option_windowSettings": { + "message": "窗口设置" + }, + "Option_windowSettings_desc": { + "message": "配置窗口行为和弹出窗口设置。" + }, + "Option_popupAutoCloseDelay": { + "message": "弹出窗口自动关闭延迟" + }, + "Option_popupAutoCloseDelay_desc": { + "message": "设置弹出窗口失去焦点后自动关闭前的延迟时间。设置为0或留空表示立即关闭。\n最大值:10000毫秒" + }, + "Option_popupAutoCloseDelay_placeholder": { + "message": "0(立即关闭)" + }, "Option_folders": { "message": "文件夹" }, diff --git a/packages/extension/src/background_script.test.ts b/packages/extension/src/background_script.test.ts index 5efa0473..93c9aa3e 100644 --- a/packages/extension/src/background_script.test.ts +++ b/packages/extension/src/background_script.test.ts @@ -7,6 +7,7 @@ import { POPUP_ENABLED, LINK_COMMAND_ENABLED } from "@/const" // Mock dependencies vi.mock("@/services/settings/enhancedSettings") vi.mock("@/services/settings/settings") +vi.mock("@/services/settings/settingsCache") vi.mock("@/services/storage") vi.mock("@/services/chrome") vi.mock("@/services/backgroundData") @@ -378,3 +379,224 @@ describe("Background Script Migration", () => { ) }) }) + +describe("Popup Auto-Close Delay", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("PAC-01: should close popups immediately when delay is not set", async () => { + // Mock WindowStackManager + const mockGetWindowsToClose = vi.fn().mockResolvedValue([ + { id: 100, commandId: "test", srcWindowId: 1 }, + ]) + const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) + + vi.doMock("@/services/windowStackManager", () => ({ + WindowStackManager: { + getWindowsToClose: mockGetWindowsToClose, + removeWindow: mockRemoveWindow, + }, + })) + + // Mock closeWindow + const mockCloseWindow = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/chrome", () => ({ + closeWindow: mockCloseWindow, + windowExists: vi.fn(), + })) + + // Mock enhancedSettings to return no delay + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: undefined, + }, + } as any) + + // Mock Storage + const mockStorageSet = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/storage", () => ({ + Storage: { + set: mockStorageSet, + }, + SESSION_STORAGE_KEY: { + SELECTION_TEXT: "selectionText", + }, + })) + + // Mock updateActiveScreenId + vi.doMock("@/services/screen", () => ({ + updateActiveScreenId: vi.fn().mockResolvedValue(undefined), + })) + + // Clear module cache and re-import + vi.resetModules() + await import("./background_script") + + // Get the registered listener + const listenerCalls = (chrome.windows.onFocusChanged.addListener as any) + .mock.calls + expect(listenerCalls.length).toBeGreaterThan(0) + const focusChangedListener = listenerCalls[listenerCalls.length - 1][0] + + // Trigger focus change + await focusChangedListener(200) + + // Wait for async operations + await vi.runAllTimersAsync() + + // Verify window was closed immediately (no setTimeout) + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockRemoveWindow).toHaveBeenCalledWith(100) + }) + + it("PAC-02: should delay popup close when delay is set", async () => { + const delay = 1000 // 1 second + + // Mock WindowStackManager + const mockGetWindowsToClose = vi.fn().mockResolvedValue([ + { id: 100, commandId: "test", srcWindowId: 1 }, + ]) + const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) + + vi.doMock("@/services/windowStackManager", () => ({ + WindowStackManager: { + getWindowsToClose: mockGetWindowsToClose, + removeWindow: mockRemoveWindow, + }, + })) + + // Mock closeWindow + const mockCloseWindow = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/chrome", () => ({ + closeWindow: mockCloseWindow, + windowExists: vi.fn(), + })) + + // Mock enhancedSettings to return delay + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: delay, + }, + } as any) + + // Mock Storage + const mockStorageSet = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/storage", () => ({ + Storage: { + set: mockStorageSet, + }, + SESSION_STORAGE_KEY: { + SELECTION_TEXT: "selectionText", + }, + })) + + // Mock updateActiveScreenId + vi.doMock("@/services/screen", () => ({ + updateActiveScreenId: vi.fn().mockResolvedValue(undefined), + })) + + // Clear module cache and re-import + vi.resetModules() + await import("./background_script") + + // Get the registered listener + const listenerCalls = (chrome.windows.onFocusChanged.addListener as any) + .mock.calls + const focusChangedListener = listenerCalls[listenerCalls.length - 1][0] + + // Trigger focus change + await focusChangedListener(200) + + // Window should not be closed immediately + expect(mockCloseWindow).not.toHaveBeenCalled() + + // Advance timers by the delay amount + await vi.advanceTimersByTimeAsync(delay) + + // Now window should be closed + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockRemoveWindow).toHaveBeenCalledWith(100) + }) + + it("PAC-03: should cancel timeout when focus returns before delay", async () => { + const delay = 1000 // 1 second + + // Mock WindowStackManager - first returns windows to close, then empty array + const mockGetWindowsToClose = vi + .fn() + .mockResolvedValueOnce([{ id: 100, commandId: "test", srcWindowId: 1 }]) + .mockResolvedValueOnce([]) // No windows to close when focus returns + const mockRemoveWindow = vi.fn().mockResolvedValue(undefined) + + vi.doMock("@/services/windowStackManager", () => ({ + WindowStackManager: { + getWindowsToClose: mockGetWindowsToClose, + removeWindow: mockRemoveWindow, + }, + })) + + // Mock closeWindow + const mockCloseWindow = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/chrome", () => ({ + closeWindow: mockCloseWindow, + windowExists: vi.fn(), + })) + + // Mock enhancedSettings to return delay + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: delay, + }, + } as any) + + // Mock Storage + const mockStorageSet = vi.fn().mockResolvedValue(undefined) + vi.doMock("@/services/storage", () => ({ + Storage: { + set: mockStorageSet, + }, + SESSION_STORAGE_KEY: { + SELECTION_TEXT: "selectionText", + }, + })) + + // Mock updateActiveScreenId + vi.doMock("@/services/screen", () => ({ + updateActiveScreenId: vi.fn().mockResolvedValue(undefined), + })) + + // Clear module cache and re-import + vi.resetModules() + await import("./background_script") + + // Get the registered listener + const listenerCalls = (chrome.windows.onFocusChanged.addListener as any) + .mock.calls + const focusChangedListener = listenerCalls[listenerCalls.length - 1][0] + + // Trigger focus change (popup loses focus) + await focusChangedListener(200) + + // Window should not be closed yet + expect(mockCloseWindow).not.toHaveBeenCalled() + + // Advance timers only halfway + await vi.advanceTimersByTimeAsync(delay / 2) + + // Focus returns to popup (no windows to close) + await focusChangedListener(100) + + // Advance remaining time + await vi.advanceTimersByTimeAsync(delay / 2 + 100) + + // Window should NOT be closed because timeout was cancelled + expect(mockCloseWindow).not.toHaveBeenCalled() + expect(mockRemoveWindow).not.toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/background_script.ts b/packages/extension/src/background_script.ts index 61881762..d6eb43eb 100644 --- a/packages/extension/src/background_script.ts +++ b/packages/extension/src/background_script.ts @@ -17,6 +17,7 @@ import { BgData } from "@/services/backgroundData" import { ContextMenu } from "@/services/contextMenus" import { closeWindow, windowExists } from "@/services/chrome" import { WindowStackManager } from "@/services/windowStackManager" +import { PopupAutoClose } from "@/services/popupAutoClose" import { isSearchCommand, isPageActionCommand } from "@/lib/utils" import { execute } from "@/action/background" import * as ActionHelper from "@/action/helper" @@ -287,9 +288,11 @@ const commandFuncs = { return } - // Remove the window. - await closeWindow(windowId, "onHidden") - await WindowStackManager.removeWindow(windowId) + // Schedule popup window to close with configured delay + const window = layer.find((w) => w.id === windowId) + if (window) { + await PopupAutoClose.scheduleClose([window], "onHidden") + } response(false) } @@ -379,11 +382,8 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => { // Get windows to close based on focus change const windowsToClose = await WindowStackManager.getWindowsToClose(windowId) - // Execute close for all windows that need to be closed - for (const window of windowsToClose) { - await closeWindow(window.id, "onFocusChanged") - await WindowStackManager.removeWindow(window.id) - } + // Schedule popup windows to close with configured delay + await PopupAutoClose.scheduleClose(windowsToClose) }) chrome.windows.onRemoved.addListener((windowId: number) => { diff --git a/packages/extension/src/components/option/SettingForm.tsx b/packages/extension/src/components/option/SettingForm.tsx index 81a86adc..8c8d6316 100644 --- a/packages/extension/src/components/option/SettingForm.tsx +++ b/packages/extension/src/components/option/SettingForm.tsx @@ -9,6 +9,7 @@ import { Eye, BookOpen, Paintbrush, + AppWindow, } from "lucide-react" import { Form } from "@/components/ui/form" @@ -79,6 +80,16 @@ const formSchema = z }) .strict(), popupPlacement: popupPlacementSchema, + windowOption: z + .object({ + sidePanelAutoHide: z.boolean(), + popupAutoCloseDelay: z + .number({ message: t("zod_number") }) + .min(0, { message: t("zod_number_min", ["0"]) }) + .max(10000, { message: t("zod_number_max", ["10000"]) }) + .optional(), + }) + .strict(), style: z.nativeEnum(STYLE), commands: z.array(commandSchema).min(1), folders: z.array(folderSchema), @@ -129,6 +140,7 @@ export function SettingForm({ className }: { className?: string }) { defaultValues: { startupMethod: emptySettings.startupMethod, popupPlacement: emptySettings.popupPlacement, + windowOption: emptySettings.windowOption, style: emptySettings.style, commands: [], // Empty array to avoid type conflicts folders: emptySettings.folders, @@ -549,31 +561,60 @@ export function SettingForm({ className }: { className?: string }) { )} {linkCommandMethod === LINK_COMMAND_STARTUP_METHOD.LEFT_CLICK_HOLD && ( + + )} + + +
+ +
+

+ + {t("windowSettings")} +

+

{t("windowSettings_desc")}

+ + {startupMethod !== STARTUP_METHOD.CONTEXT_MENU && ( (v === "" ? undefined : Number(v)), }), }} /> )} -

diff --git a/packages/extension/src/components/option/TableOfContents.tsx b/packages/extension/src/components/option/TableOfContents.tsx index c877f147..8d5be705 100644 --- a/packages/extension/src/components/option/TableOfContents.tsx +++ b/packages/extension/src/components/option/TableOfContents.tsx @@ -7,6 +7,7 @@ import { BookOpen, Paintbrush, Keyboard, + AppWindow, } from "lucide-react" import styles from "./TableOfContents.module.css" import optionCss from "./Option.module.css" @@ -21,6 +22,7 @@ export const TableOfContents = (props: Props) => { "commands", "shortcuts", "linkCommand", + "windowSettings", "pageRules", "userStyles", ] @@ -73,6 +75,8 @@ const Icon = ({ return case "shortcuts": return + case "windowSettings": + return case "pageRules": return case "userStyles": diff --git a/packages/extension/src/components/option/field/InputField.tsx b/packages/extension/src/components/option/field/InputField.tsx index faecc1ef..f28cb8a3 100644 --- a/packages/extension/src/components/option/field/InputField.tsx +++ b/packages/extension/src/components/option/field/InputField.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react" import { Input } from "@/components/ui/input" import { FormControl, @@ -8,6 +9,8 @@ import { FormDescription, } from "@/components/ui/form" import { MenuImage } from "@/components/menu/MenuImage" +import { Info } from "lucide-react" +import { Tooltip } from "@/components/Tooltip" type InputFieldType = { control: any @@ -16,6 +19,7 @@ type InputFieldType = { inputProps: React.ComponentProps unit?: string description?: string + tooltip?: string previewUrl?: string } @@ -28,9 +32,12 @@ export const InputField = ({ inputProps, unit, description, + tooltip, previewUrl, }: InputFieldType) => { const hasPreview = !isEmpty(previewUrl) + const span = useRef(null) + return ( (
- {formLabel} + + {formLabel} + {tooltip && ( + + + + )} + {description && {description}} + {tooltip && ( + + )}
{hasPreview && ( diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 8e8519c7..1f8533c1 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -56,6 +56,7 @@ export const emptySettings: SettingsType = { shortcuts: { shortcuts: [] }, windowOption: { sidePanelAutoHide: false, + popupAutoCloseDelay: 0, }, } @@ -158,6 +159,7 @@ export default { }, windowOption: { sidePanelAutoHide: false, + popupAutoCloseDelay: 0, }, } as UserSettings diff --git a/packages/extension/src/services/popupAutoClose.test.ts b/packages/extension/src/services/popupAutoClose.test.ts new file mode 100644 index 00000000..a5159a2a --- /dev/null +++ b/packages/extension/src/services/popupAutoClose.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +// Mock all dependencies before importing PopupAutoClose +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) +vi.mock("@/services/settings/settingsCache") +vi.mock("@/services/chrome", () => ({ + closeWindow: vi.fn(), +})) +vi.mock("@/services/windowStackManager", () => ({ + WindowStackManager: { + removeWindow: vi.fn(), + }, +})) + +import { PopupAutoClose } from "./popupAutoClose" +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { closeWindow } from "@/services/chrome" +import { WindowStackManager } from "@/services/windowStackManager" + +const mockEnhancedSettings = vi.mocked(enhancedSettings) +const mockCloseWindow = vi.mocked(closeWindow) +const mockWindowStackManager = vi.mocked(WindowStackManager) + +describe("PopupAutoClose", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("cancelTimer", () => { + it("should cancel pending timer", () => { + // No error should be thrown even if no timer is set + expect(() => PopupAutoClose.cancelTimer()).not.toThrow() + }) + }) + + describe("scheduleClose", () => { + it("should close windows immediately when delay is undefined", async () => { + const windows = [{ id: 100, commandId: "test", srcWindowId: 1 }] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: undefined, + }, + } as any) + + await PopupAutoClose.scheduleClose(windows) + + // Should close immediately without waiting for timer + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockWindowStackManager.removeWindow).toHaveBeenCalledWith(100) + }) + + it("should close windows immediately when delay is 0", async () => { + const windows = [{ id: 100, commandId: "test", srcWindowId: 1 }] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: 0, + }, + } as any) + + await PopupAutoClose.scheduleClose(windows) + + // Should close immediately without waiting for timer + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockWindowStackManager.removeWindow).toHaveBeenCalledWith(100) + }) + + it("should delay close when delay is set", async () => { + const delay = 1000 + const windows = [{ id: 100, commandId: "test", srcWindowId: 1 }] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: delay, + }, + } as any) + + await PopupAutoClose.scheduleClose(windows) + + // Should not close immediately + expect(mockCloseWindow).not.toHaveBeenCalled() + + // Advance timers + await vi.advanceTimersByTimeAsync(delay) + + // Should close after delay + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockWindowStackManager.removeWindow).toHaveBeenCalledWith(100) + }) + + it("should close multiple windows", async () => { + const windows = [ + { id: 100, commandId: "test1", srcWindowId: 1 }, + { id: 101, commandId: "test2", srcWindowId: 1 }, + ] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: undefined, + }, + } as any) + + await PopupAutoClose.scheduleClose(windows) + + // Should close all windows + expect(mockCloseWindow).toHaveBeenCalledTimes(2) + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onFocusChanged") + expect(mockCloseWindow).toHaveBeenCalledWith(101, "onFocusChanged") + expect(mockWindowStackManager.removeWindow).toHaveBeenCalledTimes(2) + }) + + it("should do nothing when no windows to close", async () => { + await PopupAutoClose.scheduleClose([]) + + // Should not call enhancedSettings.getSection or closeWindow + expect(mockEnhancedSettings.getSection).not.toHaveBeenCalled() + expect(mockCloseWindow).not.toHaveBeenCalled() + }) + + it("should cancel existing timer before scheduling new one", async () => { + const delay = 1000 + const windows1 = [{ id: 100, commandId: "test1", srcWindowId: 1 }] + const windows2 = [{ id: 101, commandId: "test2", srcWindowId: 1 }] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: delay, + }, + } as any) + + // Schedule first close + await PopupAutoClose.scheduleClose(windows1) + + // Advance time halfway + await vi.advanceTimersByTimeAsync(delay / 2) + + // Schedule second close (should cancel first) + await PopupAutoClose.scheduleClose(windows2) + + // Advance remaining time + await vi.advanceTimersByTimeAsync(delay / 2) + + // First window should NOT be closed (timer was cancelled) + expect(mockCloseWindow).not.toHaveBeenCalledWith(100, "onFocusChanged") + + // Second window should not be closed yet (only half time passed) + expect(mockCloseWindow).not.toHaveBeenCalled() + + // Advance remaining time for second timer + await vi.advanceTimersByTimeAsync(delay / 2) + + // Now second window should be closed + expect(mockCloseWindow).toHaveBeenCalledWith(101, "onFocusChanged") + }) + + it("should use custom reason when provided", async () => { + const windows = [{ id: 100, commandId: "test", srcWindowId: 1 }] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: 0, + }, + } as any) + + await PopupAutoClose.scheduleClose(windows, "onHidden") + + // Should close with custom reason + expect(mockCloseWindow).toHaveBeenCalledWith(100, "onHidden") + }) + + it("should handle errors and still clear timer", async () => { + const windows = [ + { id: 100, commandId: "test1", srcWindowId: 1 }, + { id: 101, commandId: "test2", srcWindowId: 1 }, + ] + + mockEnhancedSettings.getSection.mockResolvedValue({ + windowOption: { + popupAutoCloseDelay: 0, + }, + } as any) + + // Make first closeWindow call fail + mockCloseWindow.mockRejectedValueOnce(new Error("Close failed")) + + await expect(PopupAutoClose.scheduleClose(windows)).rejects.toThrow("Close failed") + + // Timer should still be cleared - verify by scheduling another close + mockCloseWindow.mockResolvedValue(undefined) + const windows2 = [{ id: 102, commandId: "test3", srcWindowId: 1 }] + + await PopupAutoClose.scheduleClose(windows2) + + // Should work fine, proving timer was cleared + expect(mockCloseWindow).toHaveBeenCalledWith(102, "onFocusChanged") + }) + }) +}) \ No newline at end of file diff --git a/packages/extension/src/services/popupAutoClose.ts b/packages/extension/src/services/popupAutoClose.ts new file mode 100644 index 00000000..a1d87bab --- /dev/null +++ b/packages/extension/src/services/popupAutoClose.ts @@ -0,0 +1,74 @@ +import { enhancedSettings } from "@/services/settings/enhancedSettings" +import { CACHE_SECTIONS } from "@/services/settings/settingsCache" +import { closeWindow } from "@/services/chrome" +import { WindowStackManager } from "@/services/windowStackManager" +import type { WindowType } from "@/types" + +/** + * Valid reasons for closing popup windows + */ +export type CloseReason = "onFocusChanged" | "onHidden" + +/** + * Popup Auto-Close Manager + * Manages automatic closing of popup windows with configurable delay + */ +export class PopupAutoClose { + private static timer: ReturnType | null = null + + /** + * Cancel any pending auto-close timer + */ + static cancelTimer(): void { + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } + + /** + * Schedule popup windows to close with configured delay + * @param windowsToClose - Array of windows to close + * @param reason - Reason for closing (e.g., "onFocusChanged", "onHidden") + * @returns Promise that resolves when windows are closed or timer is set + */ + static async scheduleClose( + windowsToClose: WindowType[], + reason: CloseReason = "onFocusChanged", + ): Promise { + // Cancel any existing timer first + this.cancelTimer() + + // If no windows to close, nothing to do + if (windowsToClose.length === 0) { + return + } + + // Get the auto-close delay setting + const userSettings = await enhancedSettings.getSection( + CACHE_SECTIONS.USER_SETTINGS, + ) + const autoCloseDelay = userSettings.windowOption.popupAutoCloseDelay + + // Define the close function + const closeWindows = async () => { + try { + for (const window of windowsToClose) { + await closeWindow(window.id, reason) + await WindowStackManager.removeWindow(window.id) + } + } finally { + this.timer = null + } + } + + // Execute close based on delay setting + if (autoCloseDelay !== undefined && autoCloseDelay > 0) { + // Delayed close: Set timeout + this.timer = setTimeout(closeWindows, autoCloseDelay) + } else { + // Immediate close: No delay configured + await closeWindows() + } + } +} diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 230c9e9b..e7f3b96d 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -79,6 +79,7 @@ export type CopyOption = "default" | "text" type WindowOption = { sidePanelAutoHide: boolean + popupAutoCloseDelay?: number } type LinkCommandStartupMethod = { diff --git a/packages/shared/tsconfig.tsbuildinfo b/packages/shared/tsconfig.tsbuildinfo index b3c515d0..64f82187 100644 --- a/packages/shared/tsconfig.tsbuildinfo +++ b/packages/shared/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/index.ts","./src/constants/index.ts","./src/constants/open-mode.ts","./src/types/command.ts","./src/types/common.ts","./src/types/index.ts","./src/utils/cn.ts","./src/utils/common.ts","./src/utils/index.ts","./src/utils/type-guards.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/index.ts","./src/constants/index.ts","./src/constants/open-mode.ts","./src/types/command.ts","./src/types/common.ts","./src/types/index.ts","./src/utils/cn.ts","./src/utils/common.ts","./src/utils/index.ts","./src/utils/type-guards.ts"],"version":"5.9.2"} \ No newline at end of file