From 913afbb32fe8c760fbef98cdeb3f37246de29daf Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 19 Oct 2026 08:19:37 +0900 Subject: [PATCH 01/84] fix: ignore a selection recorded in another tab on shortcut execution The selection text shared via session storage was not cleared on tab switching, and tabs without the content script (e.g. chrome://) can't record their selection. So a shortcut could run with a stale selection from another tab instead of falling back to the clipboard. Record the tab ID with the selection and use it only when it matches the active tab. Also re-record the selection when a tab becomes visible. Closes #498 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/service_worker.test.ts | 98 ++++++++++++-- .../src/providers/SelectContextProvider.tsx | 43 +++++- .../__tests__/SelectContextProvider.test.tsx | 124 ++++++++++++++++++ packages/extension/src/service_worker.ts | 26 +++- packages/extension/src/services/storage.ts | 3 +- .../extension/src/services/storage/const.ts | 7 + .../extension/src/services/storage/index.ts | 2 +- 7 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx diff --git a/packages/extension/src/__tests__/service_worker.test.ts b/packages/extension/src/__tests__/service_worker.test.ts index dce53350e..8963ea6f9 100644 --- a/packages/extension/src/__tests__/service_worker.test.ts +++ b/packages/extension/src/__tests__/service_worker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { enhancedSettings } from "@/services/settings/enhancedSettings" import { Settings } from "@/services/settings/settings" import { ServiceWorkerCommand } from "@/services/ipc" -import { NEW_HUB_URL, VERSION } from "@/const" +import { NEW_HUB_URL, VERSION, SHORTCUT_NO_SELECTION_BEHAVIOR } from "@/const" // Imported before any vi.doMock, so this is the real event name table. import { ANALYTICS_EVENTS } from "@/services/analytics" @@ -276,18 +276,23 @@ describe("Service Worker Migration", () => { ) }) - it("MG-01-d: should call enhancedSettings.get() in onCommand function and pass correct command to action/serviceWorker.execute", async () => { - // Setup test data - const testShortcutId = "test-shortcut-cmd" - const testCommandId = "test-command-123" + // Sets up the mocks for the onCommand listener and returns it. + const setupOnCommand = async ({ + storedSelection, + tab, + noSelectionBehavior = SHORTCUT_NO_SELECTION_BEHAVIOR.DO_NOTHING, + }: { + storedSelection: { text: string; tabId: number | null } + tab: { id: number; url: string; windowId: number } + noSelectionBehavior?: SHORTCUT_NO_SELECTION_BEHAVIOR + }) => { const initialSettings = createTestSettings() - + initialSettings.shortcuts.shortcuts[0].noSelectionBehavior = + noSelectionBehavior as any mockEnhancedSettings.get.mockResolvedValue(initialSettings as any) - // Mock chrome.tabs.query to return a valid tab - ;(chrome.tabs.query as any).mockResolvedValue([ - { id: 1, url: "https://example.com", windowId: 1 }, - ]) + // Mock chrome.tabs.query to return the active tab + ;(chrome.tabs.query as any).mockResolvedValue([tab]) // Mock the current window used to center the popup ;(chrome.windows as any).getCurrent = vi @@ -295,7 +300,7 @@ describe("Service Worker Migration", () => { .mockResolvedValue({ left: 1920, top: 0, width: 1600, height: 1000 }) // Mock Storage.get for selection text - const mockStorageGet = vi.fn().mockResolvedValue("test selection text") + const mockStorageGet = vi.fn().mockResolvedValue(storedSelection) vi.doMock("@/services/storage", () => ({ Storage: { get: mockStorageGet, @@ -343,7 +348,19 @@ describe("Service Worker Migration", () => { .calls expect(listenerCalls.length).toBeGreaterThan(0) - const commandListener = listenerCalls[0][0] + const commandListener = listenerCalls[listenerCalls.length - 1][0] + return { commandListener, mockExecute, mockIpcSendTab } + } + + it("MG-01-d: should call enhancedSettings.get() in onCommand function and pass correct command to action/serviceWorker.execute", async () => { + // Setup test data + const testShortcutId = "test-shortcut-cmd" + const testCommandId = "test-command-123" + + const { commandListener, mockExecute } = await setupOnCommand({ + storedSelection: { text: "test selection text", tabId: 1 }, + tab: { id: 1, url: "https://example.com", windowId: 1 }, + }) // Call the listener with our test shortcut command await commandListener(testShortcutId) @@ -351,7 +368,6 @@ describe("Service Worker Migration", () => { // Wait for async operations await new Promise((resolve) => setTimeout(resolve, 10)) - // This should fail initially because the function still uses Settings.get() instead of enhancedSettings.get() expect(mockEnhancedSettings.get).toHaveBeenCalled() // Verify that the execute function was called with the correct command @@ -377,6 +393,62 @@ describe("Service Worker Migration", () => { }), ) }) + + it("MG-01-e: should ignore a selection recorded in another tab and fall back to the clipboard", async () => { + // The selection was recorded in tab 1, but the shortcut is pressed in a + // chrome:// tab, where the content script can't record its selection. + const { commandListener, mockExecute, mockIpcSendTab } = + await setupOnCommand({ + storedSelection: { text: "stale selection", tabId: 1 }, + tab: { id: 2, url: "chrome://settings/", windowId: 1 }, + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + }) + + await commandListener("test-shortcut-cmd") + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockIpcSendTab).not.toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + selectionText: "", + allowClipboardFallback: true, + pageUrl: "chrome://settings/", + }), + ) + }) + + it("MG-01-f: should do nothing when the selection was recorded in another tab and noSelectionBehavior is DO_NOTHING", async () => { + const { commandListener, mockExecute, mockIpcSendTab } = + await setupOnCommand({ + storedSelection: { text: "stale selection", tabId: 1 }, + tab: { id: 2, url: "https://example.com", windowId: 1 }, + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.DO_NOTHING, + }) + + await commandListener("test-shortcut-cmd") + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockIpcSendTab).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() + }) + + it("MG-01-g: should ignore a selection whose tab is unknown", async () => { + const { commandListener, mockExecute } = await setupOnCommand({ + storedSelection: { text: "unknown tab selection", tabId: null }, + tab: { id: 2, url: "chrome://settings/", windowId: 1 }, + noSelectionBehavior: SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD, + }) + + await commandListener("test-shortcut-cmd") + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + selectionText: "", + allowClipboardFallback: true, + }), + ) + }) }) describe("Popup Auto-Close Delay", () => { diff --git a/packages/extension/src/providers/SelectContextProvider.tsx b/packages/extension/src/providers/SelectContextProvider.tsx index 4d32291ce..017a6a689 100644 --- a/packages/extension/src/providers/SelectContextProvider.tsx +++ b/packages/extension/src/providers/SelectContextProvider.tsx @@ -1,8 +1,22 @@ -import { useState, ReactNode, useEffect, useCallback } from "react" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { useState, ReactNode, useEffect, useCallback, useRef } from "react" +import { + Storage, + SESSION_STORAGE_KEY, + type SelectionTextData, +} from "@/services/storage" +import { Ipc } from "@/services/ipc" import { getSelectionText } from "@/services/dom" import { ContextType, selectContext } from "@/hooks/useSelectContext" +const fetchTabId = async (): Promise => { + try { + return (await Ipc.getTabId()) ?? null + } catch { + // e.g. side panel, which does not belong to a tab + return null + } +} + export const SelectContextProvider = ({ children, }: { @@ -11,10 +25,18 @@ export const SelectContextProvider = ({ const [selectionText, _setSelectionText] = useState("") const [target, setTarget] = useState(null) const [detectSelectionEnabled, setDetectSelectionEnabled] = useState(true) + const tabIdRef = useRef | null>(null) const setSelectionText = useCallback(async (text: string) => { _setSelectionText(text) - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, text) + // Record the tab ID with the text, so that the shortcut handler in the + // service worker can ignore a selection recorded in another tab. + tabIdRef.current ??= fetchTabId() + const tabId = await tabIdRef.current + await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, { + text, + tabId, + }) }, []) useEffect(() => { @@ -24,9 +46,24 @@ export const SelectContextProvider = ({ await setSelectionText(text) } + // The stored selection is shared by all tabs, so re-record this tab's + // selection when the tab becomes active again. An empty selection is not + // recorded, so that a frame without a selection doesn't overwrite the one + // recorded by another frame in this tab. (A selection recorded in another + // tab is ignored by the shortcut handler anyway.) + const onVisibilitychange = async () => { + if (!detectSelectionEnabled) return + if (document.visibilityState !== "visible") return + const text = getSelectionText() + if (!text) return + await setSelectionText(text) + } + document.addEventListener("selectionchange", onSelectionchange) + document.addEventListener("visibilitychange", onVisibilitychange) return () => { document.removeEventListener("selectionchange", onSelectionchange) + document.removeEventListener("visibilitychange", onVisibilitychange) } }, [detectSelectionEnabled, setSelectionText]) diff --git a/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx new file mode 100644 index 000000000..bad0462b3 --- /dev/null +++ b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, waitFor } from "@testing-library/react" +import { SelectContextProvider } from "../SelectContextProvider" +import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { Ipc } from "@/services/ipc" +import { getSelectionText } from "@/services/dom" + +vi.mock("@/services/storage", () => ({ + Storage: { + set: vi.fn(), + }, + SESSION_STORAGE_KEY: { + SELECTION_TEXT: "selectionText", + }, +})) + +vi.mock("@/services/ipc", () => ({ + Ipc: { + getTabId: vi.fn(), + }, +})) + +vi.mock("@/services/dom", () => ({ + getSelectionText: vi.fn(), +})) + +const mockSet = vi.mocked(Storage.set) +const mockGetTabId = vi.mocked(Ipc.getTabId) +const mockGetSelectionText = vi.mocked(getSelectionText) + +const setVisibilityState = (state: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }) +} + +describe("SelectContextProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSet.mockResolvedValue(true) + mockGetTabId.mockResolvedValue(10) + setVisibilityState("visible") + }) + + afterEach(() => { + setVisibilityState("visible") + }) + + it("SC-01: 正常系: 選択変更時に選択テキストとタブIDを保存する", async () => { + mockGetSelectionText.mockReturnValue("selected") + render({null}) + + document.dispatchEvent(new Event("selectionchange")) + + await waitFor(() => { + expect(mockSet).toHaveBeenCalledWith(SESSION_STORAGE_KEY.SELECTION_TEXT, { + text: "selected", + tabId: 10, + }) + }) + }) + + it("SC-02: 異常系: タブIDを取得できない場合はtabIdをnullとして保存する", async () => { + mockGetTabId.mockRejectedValue(new Error("no tab")) + mockGetSelectionText.mockReturnValue("selected") + render({null}) + + document.dispatchEvent(new Event("selectionchange")) + + await waitFor(() => { + expect(mockSet).toHaveBeenCalledWith(SESSION_STORAGE_KEY.SELECTION_TEXT, { + text: "selected", + tabId: null, + }) + }) + }) + + it("SC-03: 正常系: タブIDの取得は一度だけ行う", async () => { + mockGetSelectionText.mockReturnValue("selected") + render({null}) + + document.dispatchEvent(new Event("selectionchange")) + document.dispatchEvent(new Event("selectionchange")) + + await waitFor(() => expect(mockSet).toHaveBeenCalledTimes(2)) + expect(mockGetTabId).toHaveBeenCalledTimes(1) + }) + + it("SC-04: 正常系: タブが表示されたときに選択中のテキストを再保存する", async () => { + mockGetSelectionText.mockReturnValue("kept selection") + render({null}) + + document.dispatchEvent(new Event("visibilitychange")) + + await waitFor(() => { + expect(mockSet).toHaveBeenCalledWith(SESSION_STORAGE_KEY.SELECTION_TEXT, { + text: "kept selection", + tabId: 10, + }) + }) + }) + + it("SC-05: 境界値: タブが表示されたときに選択が空なら保存しない", async () => { + mockGetSelectionText.mockReturnValue("") + render({null}) + + document.dispatchEvent(new Event("visibilitychange")) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockSet).not.toHaveBeenCalled() + }) + + it("SC-06: 境界値: タブが非表示になったときは保存しない", async () => { + mockGetSelectionText.mockReturnValue("selected") + setVisibilityState("hidden") + render({null}) + + document.dispatchEvent(new Event("visibilitychange")) + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockSet).not.toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/service_worker.ts b/packages/extension/src/service_worker.ts index 3c7983038..a6ae86833 100644 --- a/packages/extension/src/service_worker.ts +++ b/packages/extension/src/service_worker.ts @@ -28,7 +28,11 @@ import { findMatchingPageRule, isEmpty } from "@/lib/utils" import { execute } from "@/action/serviceWorker" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { + Storage, + SESSION_STORAGE_KEY, + type SelectionTextData, +} from "@/services/storage" import { ANALYTICS_EVENTS, sendEvent, @@ -397,7 +401,10 @@ const clearSelectionTextUnlessKeepOpen = async () => { CACHE_SECTIONS.USER_SETTINGS, ) if (!settings.startupMethod?.keepMenuOpenOnFocusChange) { - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, "") + await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, { + text: "", + tabId: null, + }) } } @@ -696,9 +703,19 @@ chrome.commands.onCommand.addListener(async (commandName) => { return } - const selectionText = await Storage.get( + // Get active tab + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + + // Use the selection only if it was recorded in the active tab. Tabs + // without the content script (e.g. chrome://) can't record their + // selection, so the stored one may belong to another tab. + const selection = await Storage.get( SESSION_STORAGE_KEY.SELECTION_TEXT, ) + const selectionText = + selection?.tabId != null && selection.tabId === tab?.id + ? selection.text + : "" // If no text is selected, handle according to noSelectionBehavior let allowClipboardFallback = false @@ -715,9 +732,6 @@ chrome.commands.onCommand.addListener(async (commandName) => { allowClipboardFallback = true } } - - // Get active tab - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) const enableSendTab = tab?.id && !tab.url?.startsWith("chrome") && diff --git a/packages/extension/src/services/storage.ts b/packages/extension/src/services/storage.ts index 39a962e48..98ecefdd9 100644 --- a/packages/extension/src/services/storage.ts +++ b/packages/extension/src/services/storage.ts @@ -5,6 +5,7 @@ import { SESSION_STORAGE_KEY, CMD_PREFIX, KEY, + SelectionTextData, } from "./storage/const" import { BaseStorage, ChangedCallback } from "./storage/index" import { @@ -27,7 +28,7 @@ export { WeeklyBackupManager, } -export type { ChangedCallback, KEY } +export type { ChangedCallback, KEY, SelectionTextData } export const Storage = { // Base storage methods diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index c0463b3fa..bf0bb8f2d 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -36,6 +36,13 @@ export enum SESSION_STORAGE_KEY { DEV_TOOLS_ENABLED = "devToolsEnabled", } +// Selection text recorded by the tab where the text was selected. +export type SelectionTextData = { + text: string + // ID of the tab that recorded the selection. null if unknown. + tabId: number | null +} + export type KEY = | STORAGE_KEY | LOCAL_STORAGE_KEY diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 9339ae985..9621c0281 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -105,7 +105,7 @@ const DEFAULTS = { [SESSION_STORAGE_KEY.PA_CONTEXT]: {}, [SESSION_STORAGE_KEY.PA_RECORDER_OPTION]: {}, [SESSION_STORAGE_KEY.TMP_CAPTURES]: {}, - [SESSION_STORAGE_KEY.SELECTION_TEXT]: "", + [SESSION_STORAGE_KEY.SELECTION_TEXT]: { text: "", tabId: null }, [SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING]: null, [SESSION_STORAGE_KEY.DEV_TOOLS_ENABLED]: false, } as const From da08ffd78814aaf2c296b773a0149e1ea18f1391 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 19 Oct 2026 08:49:59 +0900 Subject: [PATCH 02/84] refactor(hub): stop using Tailwind CSS and shadcn/ui The hub only serves the e2e test page and static data files, so a CSS framework brings no benefit. Replace Tailwind classes with plain CSS (globals.css + CSS Modules) and shadcn/ui components with native elements, keeping the test page DOM that e2e relies on unchanged. Also add E2E_HUB_BASE_URL to route the deployed hub to a local build so hub changes can be verified with e2e before deploying. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KWGX774FR5iixzXqS7CMpU --- packages/extension/e2e/fixtures.ts | 4 + packages/extension/e2e/utils/hubRoute.ts | 34 ++++ packages/hub/AGENTS.md | 16 +- packages/hub/components.json | 21 --- packages/hub/package.json | 10 +- packages/hub/postcss.config.mjs | 8 - packages/hub/src/app/[lang]/test/page.tsx | 131 ++++++++------- .../hub/src/app/[lang]/test/test.module.css | 120 +++++++++++++ packages/hub/src/app/globals.css | 154 +++++++---------- packages/hub/src/app/layout.tsx | 4 +- packages/hub/src/app/not-found.tsx | 35 +--- packages/hub/src/app/page.module.css | 77 +++------ packages/hub/src/app/page.tsx | 35 +--- packages/hub/src/components/Image.tsx | 3 +- .../src/components/LocaleSelector.module.css | 36 ++++ .../hub/src/components/LocaleSelector.tsx | 43 ++--- .../hub/src/components/MovedNotice.module.css | 87 ++++++++++ packages/hub/src/components/MovedNotice.tsx | 32 ++++ .../src/components/layout/Footer.module.css | 23 +++ packages/hub/src/components/layout/Footer.tsx | 10 +- .../src/components/layout/Header.module.css | 65 ++++++- packages/hub/src/components/layout/Header.tsx | 11 +- packages/hub/src/components/ui/button.tsx | 54 ------ packages/hub/src/components/ui/select.tsx | 159 ------------------ packages/hub/src/components/ui/separator.tsx | 31 ---- packages/hub/src/lib/__tests__/utils.test.ts | 40 ----- packages/hub/src/lib/utils.ts | 1 - packages/hub/tailwind.config.ts | 49 ------ packages/hub/vitest.config.ts | 1 - yarn.lock | 20 +-- 30 files changed, 609 insertions(+), 705 deletions(-) create mode 100644 packages/extension/e2e/utils/hubRoute.ts delete mode 100644 packages/hub/components.json delete mode 100644 packages/hub/postcss.config.mjs create mode 100644 packages/hub/src/app/[lang]/test/test.module.css create mode 100644 packages/hub/src/components/LocaleSelector.module.css create mode 100644 packages/hub/src/components/MovedNotice.module.css create mode 100644 packages/hub/src/components/MovedNotice.tsx create mode 100644 packages/hub/src/components/layout/Footer.module.css delete mode 100644 packages/hub/src/components/ui/button.tsx delete mode 100644 packages/hub/src/components/ui/select.tsx delete mode 100644 packages/hub/src/components/ui/separator.tsx delete mode 100644 packages/hub/tailwind.config.ts diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 85ed61a3e..ae8aa8e6e 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -8,6 +8,7 @@ import path from "path" import { fileURLToPath } from "url" import { attachSWConsole } from "./utils/logConsole" import { fetchCfAuthToken } from "./utils/cfAuth" +import { routeHubToLocal } from "./utils/hubRoute" import { STORAGE_KEY, LOCAL_STORAGE_KEY, @@ -64,6 +65,9 @@ export const test = base.extend({ // and its early logs are lost before attachSWConsole is called. context.on("serviceworker", (worker) => attachSWConsole(worker)) + // Optionally serve the hub test page from a local build (E2E_HUB_BASE_URL). + await routeHubToLocal(context) + // Wait for the service worker to be ready before proceeding, so tests can interact with it immediately. let [sw] = context.serviceWorkers() if (!sw) { diff --git a/packages/extension/e2e/utils/hubRoute.ts b/packages/extension/e2e/utils/hubRoute.ts new file mode 100644 index 000000000..8a6168981 --- /dev/null +++ b/packages/extension/e2e/utils/hubRoute.ts @@ -0,0 +1,34 @@ +import type { BrowserContext } from "@playwright/test" + +const DEPLOYED_HUB_ORIGIN = "https://ujiro99.github.io" +const DEPLOYED_HUB_BASE_PATH = "/selection-command" + +/** + * Redirects requests for the deployed hub (GitHub Pages) to a locally served + * hub build, so that hub changes can be verified with e2e before deploying. + * + * Enabled only when E2E_HUB_BASE_URL is set (e.g. "http://localhost:3100"), + * pointing at a static server for `packages/hub/out`. + * The local build has no basePath, so both "/selection-command/*" pages and + * the root-relative "/_next/*" assets they reference are mapped to the + * server root. Extensionless paths are mapped to their exported ".html" file. + */ +export async function routeHubToLocal(context: BrowserContext): Promise { + const localBaseUrl = process.env.E2E_HUB_BASE_URL + if (!localBaseUrl) return + + await context.route(`${DEPLOYED_HUB_ORIGIN}/**`, async (route) => { + const url = new URL(route.request().url()) + let pathname = url.pathname + if (pathname.startsWith(DEPLOYED_HUB_BASE_PATH)) { + pathname = pathname.slice(DEPLOYED_HUB_BASE_PATH.length) || "/" + } + if (!/\.[a-z0-9]+$/i.test(pathname)) { + pathname = `${pathname.replace(/\/$/, "/index")}.html` + } + const response = await route.fetch({ + url: `${localBaseUrl}${pathname}${url.search}`, + }) + await route.fulfill({ response }) + }) +} diff --git a/packages/hub/AGENTS.md b/packages/hub/AGENTS.md index d0bc12a61..28e31622c 100644 --- a/packages/hub/AGENTS.md +++ b/packages/hub/AGENTS.md @@ -75,8 +75,9 @@ This file provides guidance to AI Agent when working with code in this repositor - `src/app/[lang]/layout.tsx`, `src/app/layout.tsx` - App Router の必須レイアウト(Header/Footer/LanguageProvider を保持) - `src/features/locale/` - 14言語分の辞書ファイル(layout.tsx が 無条件にバレルインポートするため削除できない) -- `src/components/ui/` - shadcn/ui のうち button, separator, select のみ残存 -- `src/lib/utils.ts` - 共通ユーティリティ(`cn` 等、`@shared` からの re-export) +- `src/components/MovedNotice.tsx` - ルートページと 404 ページで共有する移転案内 +- `src/lib/utils.ts` - 共通ユーティリティ(`@shared` からの re-export 等) +- `src/app/globals.css` + 各 `*.module.css` - スタイル(素の CSS のみ。下記参照) ## 削除済みの機能 @@ -97,3 +98,14 @@ Google Analytics 連携・お問い合わせフォーム等は、全て新リポ 壊れないことを確認すること - `src/app/[lang]/test/page.tsx` を変更する場合は、`packages/extension/e2e/` の 対応する spec が壊れないことを確認すること(本番反映後の e2e 実行で最終確認) + - デプロイ前の確認は、`yarn build` した `out/` をローカルで配信し、 + `E2E_HUB_BASE_URL` を指定して extension の e2e を実行する + (例: `python3 -m http.server 3100 -d packages/hub/out` → + `E2E_HUB_BASE_URL=http://localhost:3100 yarn test:e2e`)。 + `ujiro99.github.io` へのリクエストがローカルへ転送される + (`packages/extension/e2e/utils/hubRoute.ts`) +- スタイリングは素の CSS(`globals.css` と CSS Modules)のみで行う。 + Tailwind CSS や shadcn/ui などの CSS フレームワーク・UI ライブラリは導入しない + (テストページと設定値のホスティングのみが目的で、導入するメリットがないため) +- テストページの DOM 構造(見出し、`data-testid`、`placeholder`、文言など)は + e2e が参照しているため、スタイル変更時も維持すること diff --git a/packages/hub/components.json b/packages/hub/components.json deleted file mode 100644 index a5577c5db..000000000 --- a/packages/hub/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "src/app/globals.css", - "baseColor": "stone", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} diff --git a/packages/hub/package.json b/packages/hub/package.json index f86808553..7bc28beef 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -14,17 +14,11 @@ "pretty-quick": "pretty-quick" }, "dependencies": { - "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "lucide-react": "^1.24.0", "next": "^15.1.0", "quill": "^2.0.3", "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7" + "react-dom": "^19.0.0" }, "devDependencies": { "@next/eslint-plugin-next": "^15.1.0", @@ -40,9 +34,7 @@ "@vitest/ui": "^3.2.4", "eslint-config-next": "^15.1.0", "jsdom": "^26.1.0", - "postcss": "^8", "pretty-quick": "^4.2.2", - "tailwindcss": "^3.4.1", "typescript": "^5", "typescript-eslint": "^8.18.2", "vite": "^6.0.0" diff --git a/packages/hub/postcss.config.mjs b/packages/hub/postcss.config.mjs deleted file mode 100644 index 0dc456ad9..000000000 --- a/packages/hub/postcss.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - tailwindcss: {}, - }, -} - -export default config diff --git a/packages/hub/src/app/[lang]/test/page.tsx b/packages/hub/src/app/[lang]/test/page.tsx index 827163ece..29f7ca5fd 100644 --- a/packages/hub/src/app/[lang]/test/page.tsx +++ b/packages/hub/src/app/[lang]/test/page.tsx @@ -1,8 +1,8 @@ "use client" import { Send } from "lucide-react" -import css from "@/app/page.module.css" -import { Button } from "@/components/ui/button" +import layoutCss from "@/app/page.module.css" +import css from "./test.module.css" import dynamic from "next/dynamic" import "quill/dist/quill.snow.css" import { useSearchParams } from "next/navigation" @@ -18,7 +18,7 @@ function SearchQuery() { const searchParams = useSearchParams() const search = searchParams.get("k") ?? "" return ( -
+    
       {search}
     
) @@ -71,62 +71,62 @@ export default function Page() { } return ( -
-
-

Browser Feature Test

+
+
+

Browser Feature Test

-
-

Search Query Parameter

+
+

Search Query Parameter

Loading...
}> -
+
-

Text

+

Text

-

Password

+

Password

-

Number

+

Number

-

Textarea

- + + + diff --git a/packages/extension/src/offscreen_clipboard.ts b/packages/extension/src/offscreen_clipboard.ts new file mode 100644 index 000000000..1db0aef54 --- /dev/null +++ b/packages/extension/src/offscreen_clipboard.ts @@ -0,0 +1,32 @@ +import type { ClipboardResult } from "./services/ipc" + +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if ( + sender.id !== chrome.runtime.id || + sender.tab || + message?.target !== "offscreen-clipboard" || + message.command !== "read" + ) { + return false + } + + const textarea = document.getElementById("clipboard") as HTMLTextAreaElement + try { + textarea.value = "" + textarea.focus() + textarea.select() + // Async Clipboard requires document focus, which offscreen documents lack. + if (!document.execCommand("paste")) { + throw new Error("Failed to paste clipboard text") + } + respond({ data: textarea.value } satisfies ClipboardResult) + } catch (error) { + respond({ + data: "", + err: error instanceof Error ? error.message : String(error), + } satisfies ClipboardResult) + } finally { + textarea.value = "" + } + return false +}) diff --git a/packages/extension/src/services/__tests__/chrome.openTab.test.ts b/packages/extension/src/services/__tests__/chrome.openTab.test.ts new file mode 100644 index 000000000..df487358f --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openTab.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openTab } from "../chrome" +import { Ipc, ServiceWorkerCommand } from "../ipc" + +const sourceTab = { id: 10, windowId: 1, index: 2 } as chrome.tabs.Tab +const resultTab = { id: 20, windowId: 1 } as chrome.tabs.Tab +const url = { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, +} + +function provideClipboard(data: string, err?: string) { + vi.mocked(chrome.runtime.onConnect.addListener).mockImplementation( + (listener) => { + const port = { + sender: { tab: { id: 30 } }, + onMessage: { + addListener: (onMessage: (message: unknown) => void) => { + onMessage({ + command: ServiceWorkerCommand.setClipboard, + data: { data, err }, + }) + }, + removeListener: vi.fn(), + }, + } as unknown as chrome.runtime.Port + listener(port) + }, + ) +} + +describe("openTab", () => { + afterEach(() => vi.unstubAllGlobals()) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(chrome.tabs.query).mockImplementation(() => + Promise.resolve([sourceTab]), + ) + vi.mocked(chrome.tabs.create).mockImplementation(() => + Promise.resolve(resultTab), + ) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + create: vi.fn().mockResolvedValue({ id: 3, tabs: [{ id: 30 }] }), + remove: vi.fn().mockResolvedValue(undefined), + }, + }) + provideClipboard("clipboard text") + }) + + it("COT-01: 正常系: クリップボードを一時ウィンドウで読み非アクティブタブに渡す", async () => { + const result = await openTab({ url, active: false }) + + expect(chrome.windows.create).toHaveBeenCalledOnce() + expect(chrome.windows.remove).toHaveBeenCalledWith(3) + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + active: false, + windowId: 1, + index: 3, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + expect(result).toEqual({ tabId: 20, clipboardText: "clipboard text" }) + }) + + it("COT-02: 正常系: クリップボード不要なら一時ウィンドウを開かない", async () => { + await openTab({ + url: { ...url, selectionText: "selected", selectionFromClipboard: false }, + active: false, + }) + + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/?q=selected", + active: false, + }), + ) + }) + + it("COT-03: 異常系: 読み取り失敗時も非アクティブで開きエラーを通知する", async () => { + provideClipboard("", "Permission denied") + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + const result = await openTab({ url, active: false }) + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ active: false }), + ) + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + expect(chrome.windows.remove).toHaveBeenCalledWith(3) + expect(result).toEqual({ tabId: 20, clipboardText: "" }) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts new file mode 100644 index 000000000..b8826ffc9 --- /dev/null +++ b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { readOffscreenClipboard } from "../offscreenClipboard" + +const createDocument = vi.fn() +const closeDocument = vi.fn() +const getContexts = vi.fn() +const sendMessage = vi.fn() + +describe("readOffscreenClipboard", () => { + beforeEach(() => { + vi.resetAllMocks() + createDocument.mockResolvedValue(undefined) + closeDocument.mockResolvedValue(undefined) + getContexts.mockResolvedValue([]) + sendMessage.mockResolvedValue({ data: "日本語 clipboard" }) + vi.stubGlobal("chrome", { + ...chrome, + runtime: { + ...chrome.runtime, + getURL: (path: string) => `chrome-extension://test/${path}`, + getContexts, + ContextType: { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" }, + sendMessage, + }, + offscreen: { + createDocument, + closeDocument, + Reason: { CLIPBOARD: "CLIPBOARD" }, + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("OC-01: 正常系: 非表示ドキュメントで読み取り後に閉じる", async () => { + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledWith( + expect.objectContaining({ + url: "src/offscreen_clipboard.html", + reasons: ["CLIPBOARD"], + }), + ) + expect(closeDocument).toHaveBeenCalledOnce() + }) + + it("OC-02: 正常系: 再起動後に残ったドキュメントを再利用する", async () => { + getContexts.mockResolvedValue([{}]) + await readOffscreenClipboard() + expect(createDocument).not.toHaveBeenCalled() + expect(closeDocument).toHaveBeenCalledOnce() + }) + + it("OC-03: 異常系: 読み取り失敗時も閉じてエラーを返す", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Read failed", + }) + expect(closeDocument).toHaveBeenCalledOnce() + }) + + it("OC-04: 正常系: 同時要求では前のドキュメントを閉じてから次を作る", async () => { + const events: string[] = [] + createDocument.mockImplementation(async () => { + events.push("create") + }) + sendMessage.mockImplementation(async () => { + events.push("read") + return { data: "text" } + }) + closeDocument.mockImplementation(async () => { + events.push("close") + }) + await Promise.all([readOffscreenClipboard(), readOffscreenClipboard()]) + expect(events).toEqual([ + "create", + "read", + "close", + "create", + "read", + "close", + ]) + }) +}) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 1b3661109..5911468c7 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -1,3 +1,4 @@ +import { readOffscreenClipboard } from "./offscreenClipboard" import { sleep, toUrl, isOverBytes, needsClipboard } from "@/lib/utils" import type { ScreenSize } from "@/services/dom" import type { ShowToastParam, UrlParam, WindowLayer } from "@/types" @@ -405,18 +406,20 @@ const openWindowAndReadClipboard = async ( } } -/** - * Open a temporary window to read clipboard content and return the result. - * @returns {Promise<{ clipboardText: string; err?: string }>} The clipboard content and any error message - * - * Note: This function opens a temporary window to read the clipboard content, - * which is necessary due to browser security restrictions. - * The window is closed immediately after reading the clipboard. - */ +/** Read clipboard text without a visible window when Offscreen API is available. */ export const readClipboard = async (): Promise<{ clipboardText: string err?: string }> => { + if ( + typeof chrome.offscreen?.createDocument === "function" && + typeof chrome.runtime.getContexts === "function" + ) { + const result = await readOffscreenClipboard() + return { clipboardText: result.data ?? "", err: result.err } + } + + // Preserve compatibility with browsers without the Offscreen API. const w = await chrome.windows.create({ url: chrome.runtime.getURL("src/clipboard.html"), focused: true, @@ -606,47 +609,47 @@ export const openTab = async (param: OpenTabProps): Promise => { console.warn("Failed to get current tab:", e) } - if (needsClipboard(url)) { - const tab = await chrome.tabs.create({ + const clipboardRequired = needsClipboard(url) + let tab: chrome.tabs.Tab + let result: ClipboardResult = { data: "" } + + if (clipboardRequired && active) { + tab = await chrome.tabs.create({ url: chrome.runtime.getURL("src/clipboard.html"), active: true, windowId: currentTab?.windowId, index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, }) - - const result = await readClipboardContent(tab.id as number) + result = await readClipboardContent(tab.id as number) await chrome.tabs.update(tab.id as number, { url: toUrl(url, result.data ?? ""), }) - - if (result.err) { - await Ipc.ensureConnection(tab.id as number) - await Ipc.sendTab( - tab.id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } - - return { - tabId: tab.id as number, - clipboardText: result.data ?? "", - } } else { - const tab = await chrome.tabs.create({ - url: toUrl(url), + if (clipboardRequired) { + // Read separately so the destination tab stays inactive. + const clipboard = await readClipboard() + result = { data: clipboard.clipboardText, err: clipboard.err } + } + tab = await chrome.tabs.create({ + url: toUrl(url, result.data ?? ""), active, windowId: currentTab?.windowId, index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, }) - return { - tabId: tab.id as number, - clipboardText: "", - } + } + + if (result.err) { + await Ipc.ensureConnection(tab.id as number) + await Ipc.sendTab(tab.id as number, TabCommand.showToast, { + title: t("clipboard_error_title"), + description: t("clipboard_error_description"), + action: t("clipboard_error_action"), + }) + } + + return { + tabId: tab.id as number, + clipboardText: result.data ?? "", } } diff --git a/packages/extension/src/services/offscreenClipboard.ts b/packages/extension/src/services/offscreenClipboard.ts new file mode 100644 index 000000000..ebfdc781e --- /dev/null +++ b/packages/extension/src/services/offscreenClipboard.ts @@ -0,0 +1,47 @@ +import type { ClipboardResult } from "./ipc" + +let pendingRead: Promise = Promise.resolve() + +async function readOnce(): Promise { + const documentUrl = chrome.runtime.getURL("src/offscreen_clipboard.html") + let documentReady = false + try { + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [documentUrl], + }) + if (contexts.length === 0) { + await chrome.offscreen.createDocument({ + url: "src/offscreen_clipboard.html", + reasons: [chrome.offscreen.Reason.CLIPBOARD], + justification: "Read clipboard text without opening a visible window", + }) + } + documentReady = true + return await chrome.runtime.sendMessage({ + target: "offscreen-clipboard", + command: "read", + }) + } catch (error) { + return { + data: "", + err: error instanceof Error ? error.message : String(error), + } + } finally { + if (documentReady) { + await chrome.offscreen.closeDocument().catch((error) => { + console.warn("Failed to close clipboard document:", error) + }) + } + } +} + +export function readOffscreenClipboard(): Promise { + // Only one offscreen document may exist. Serialize its complete lifecycle. + const read = pendingRead.then(readOnce) + pendingRead = read.then( + () => undefined, + () => undefined, + ) + return read +} diff --git a/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts new file mode 100644 index 000000000..c31bc8cb3 --- /dev/null +++ b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest" +import { PageActionDispatcher } from "../dispatcher" +import { SelectorType, PAGE_ACTION_EVENT } from "@/const" + +const codeText = ` const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [documentUrl], + })` + +const inputCases = [ + { tag: "textarea", text: codeText }, + { + tag: "input", + text: "[KeyA] [Enter] {Enter} {Control>} [] [[ {{ [ 日本語 \\", + }, +] as const + +describe("PageActionDispatcher literal text input", () => { + afterEach(() => document.body.replaceChildren()) + + for (const source of ["Clipboard", "SelectedText", "literal"] as const) { + for (const { tag, text } of inputCases) { + it(`PDI-LITERAL-${source}-${tag}: 正常系: 括弧を含む${source}を${tag}へそのまま入力する`, async () => { + const element = document.createElement(tag) + element.id = "target" + document.body.append(element) + + const result = await PageActionDispatcher.input({ + type: PAGE_ACTION_EVENT.input, + label: "Target", + selector: "#target", + selectorType: SelectorType.css, + value: source === "literal" ? text : `{{${source}}}`, + clipboardText: source === "Clipboard" ? text : "", + selectedText: source === "SelectedText" ? text : "", + srcUrl: "https://example.com/", + }) + + expect(result).toEqual([true]) + expect(element.value).toBe(text) + }) + } + } +}) diff --git a/packages/extension/src/services/pageAction/dispatcher.ts b/packages/extension/src/services/pageAction/dispatcher.ts index 85339ea9d..b1e455590 100644 --- a/packages/extension/src/services/pageAction/dispatcher.ts +++ b/packages/extension/src/services/pageAction/dispatcher.ts @@ -139,7 +139,8 @@ export const PageActionDispatcher = { if (param.clearBefore) clearInput(element) await inputContentEditable(element, value, 10, null) } else { - value = value.replace(/{/g, "{{") // escape + // Treat input text literally instead of user-event keyboard descriptors. + value = value.replaceAll("{", "{{").replaceAll("[", "[[") const isTextControl = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 1a101b89a..0f65c1e25 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -172,6 +172,7 @@ export default defineConfig(({ mode }) => { rollupOptions: { input: { clipboard: "src/clipboard.html", + offscreenClipboard: "src/offscreen_clipboard.html", onboarding: "src/onboarding_page.html", }, }, From 4a1fffa65158c04712c0c4a25f43278d34cdf86a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 13:08:05 +0900 Subject: [PATCH 29/84] =?UTF-8?q?refactor:=20=E3=82=AF=E3=83=AA=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=9C=E3=83=BC=E3=83=89=E8=AA=AD=E3=81=BF=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E3=82=92=E5=86=8D=E5=88=A9=E7=94=A8=E3=81=99=E3=82=8B?= =?UTF-8?q?Offscreen=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/clipboard.md | 36 +++ docs/test/e2e-test-spec.md | 5 +- packages/extension/e2e/shortcut.spec.ts | 45 +++- packages/extension/manifest.json | 1 + packages/extension/src/clipboard.html | 10 - packages/extension/src/clipboard.ts | 47 ---- .../__tests__/chrome.openPopupWindow.test.ts | 108 ++++++++ .../services/__tests__/chrome.openTab.test.ts | 55 ++-- .../__tests__/offscreenClipboard.test.ts | 70 +++-- packages/extension/src/services/chrome.ts | 250 ++++-------------- packages/extension/src/services/ipc.ts | 1 - .../src/services/offscreenClipboard.ts | 11 +- packages/extension/vite.config.ts | 1 - 13 files changed, 302 insertions(+), 338 deletions(-) create mode 100644 docs/design/clipboard.md delete mode 100644 packages/extension/src/clipboard.html delete mode 100644 packages/extension/src/clipboard.ts create mode 100644 packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts diff --git a/docs/design/clipboard.md b/docs/design/clipboard.md new file mode 100644 index 000000000..eae5b4176 --- /dev/null +++ b/docs/design/clipboard.md @@ -0,0 +1,36 @@ +# クリップボード読み取り + +Chrome 116相当以降を対象とし、Service Workerからの読み取りは +`readClipboard()` → `readOffscreenClipboard()` に統一する。 +`clipboard.html` による表示タブ・ウィンドウでの読み取りは使用しない。 + +## 作成コストと再利用 + +- 最初の読み取りでOffscreen Documentを遅延作成する。 +- 読み取り後もドキュメントを維持し、次回以降は同じドキュメントを再利用する。 +- 読み取りは直列化し、同時要求による二重作成や結果の混在を防ぐ。 +- Service Worker再起動時も `runtime.getContexts()` で既存ドキュメントを検出する。 +- Chromeによってドキュメントが破棄された場合は、次の要求で再作成する。 +- textareaの内容は読み取り直後に消去し、クリップボード本文を保持しない。 + +Offscreen Documentを表示先のタブ・ウィンドウへ転用するAPIはないため、 +従来の「読み取り画面を表示先として転用する」最適化はそのまま移植できない。 +代わりに、読み取り側のドキュメントを要求間で再利用する設計とする。 + +従来は、表示先タブ・ウィンドウで読み取った後、その画面を目的URLへ遷移させていた。 +新方式では読み取り用のドキュメントを繰り返し再利用し、表示先は完成したURLで +一度だけ作成する。初回は非表示ドキュメントの作成待ちが必要だが、次回以降は +読み取りごとの作成・破棄と、表示先での中間ページの読み込みを避けられる。 +ブラウザによるレンダラープロセスの割り当ては制御しないため、 +初回を含めて常に高速になるとは保証しない。 + +## 読み取りAPI + +Offscreen Documentはフォーカスできない。検証環境では +`navigator.clipboard.readText()` は存在するものの、 +`Document is not focused` で失敗したため、`clipboardRead` 権限と +textareaへの `document.execCommand("paste")` を使用する。 +APIを将来変更する際は、非フォーカス状態での読み取りが成立することを確認する。 + +- [Offscreen API](https://developer.chrome.com/docs/extensions/reference/api/offscreen) +- [runtime.getContexts](https://developer.chrome.com/docs/extensions/reference/api/runtime#method-getContexts) diff --git a/docs/test/e2e-test-spec.md b/docs/test/e2e-test-spec.md index 0826539dd..832b66ffc 100644 --- a/docs/test/e2e-test-spec.md +++ b/docs/test/e2e-test-spec.md @@ -1091,7 +1091,10 @@ TypeScriptコードを設定する。`[KeyA]`、`{Enter}`、`[]`、`[[`、`{{` バックグラウンドタブでは `readClipboard()` がOffscreen Documentで読み取り、 結果タブを非アクティブで作成する。E2E-107/110ではウィンドウが新規作成されないこと、 結果タブの非アクティブ状態と元ページのフォーカス維持も検証する。 -Offscreen API非対応環境では従来の一時ウィンドウ方式を使用する。 +全表示モードでOffscreenに統一し、Chrome 116相当以降を対応範囲とする。 +E2E-107ではクリップボードを書き換えて再実行し、最新の入力値と +Offscreen Documentの `documentId` が同一であること(再利用)も検証する。 +設計の詳細は [クリップボード読み取り](../design/clipboard.md) を参照。 | テストID | 選択状態 | 入力ステップ | 表示方法 | 期待する入力値の変数部分 | | -------- | -------- | ------------------ | -------------------- | ------------------------------------------ | diff --git a/packages/extension/e2e/shortcut.spec.ts b/packages/extension/e2e/shortcut.spec.ts index 6b4401b95..dc994f5e1 100644 --- a/packages/extension/e2e/shortcut.spec.ts +++ b/packages/extension/e2e/shortcut.spec.ts @@ -277,17 +277,7 @@ for (const { id, openMode, variable, hasSelection } of pageActionCases) { }) } - // Popup mode may first load the clipboard page before the destination. - const resultPromise = context.waitForEvent("page", { - predicate: async (candidate) => { - try { - await candidate.waitForURL(`${targetUrl}?shortcutPageAction=${id}`) - return true - } catch { - return false - } - }, - }) + const resultPromise = context.waitForEvent("page") await runShortcut(context) const result = await resultPromise await expect(result).toHaveURL(`${targetUrl}?shortcutPageAction=${id}`) @@ -317,6 +307,39 @@ for (const { id, openMode, variable, hasSelection } of pageActionCases) { expect(createdWindowCount).toBe(0) expect(resultTab.active).toBe(false) expect(await page.evaluate(() => document.hasFocus())).toBe(true) + if (id === "E2E-107") { + const worker = context.serviceWorkers()[0] + const getDocumentId = () => + worker.evaluate(async () => { + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + }) + return contexts[0]?.documentId + }) + const documentId = await getDocumentId() + expect(documentId).toBeTruthy() + const nextText = "Updated clipboard [second] 日本語" + await page.evaluate( + (text) => navigator.clipboard.writeText(text), + nextText, + ) + const nextResultPromise = context.waitForEvent("page") + await runShortcut(context) + const nextResult = await nextResultPromise + await expect(nextResult.locator("textarea")).toHaveValue( + `Input: ${nextText}`, + { timeout: 10_000 }, + ) + expect(await getDocumentId()).toBe(documentId) + expect(await page.evaluate(() => document.hasFocus())).toBe(true) + expect( + await worker.evaluate( + () => + (globalThis as typeof globalThis & { createdWindowCount: number }) + .createdWindowCount, + ), + ).toBe(0) + } } }) } diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 678556e11..308026a95 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -1,5 +1,6 @@ { "manifest_version": 3, + "minimum_chrome_version": "116", "name": "__MSG_extName__", "description": "__MSG_extDescription__", "version": "1.3.2", diff --git a/packages/extension/src/clipboard.html b/packages/extension/src/clipboard.html deleted file mode 100644 index 1a4dd1449..000000000 --- a/packages/extension/src/clipboard.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Selection Command - - - - - diff --git a/packages/extension/src/clipboard.ts b/packages/extension/src/clipboard.ts deleted file mode 100644 index 4d1628e05..000000000 --- a/packages/extension/src/clipboard.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ClipboardResult, ServiceWorkerCommand } from "@/services/ipc" - -/** - * Read text from clipboard with retry mechanism - * @param maxRetries Maximum number of retry attempts (default: 3) - * @param delayMs Delay between retries in milliseconds (default: 100) - * @returns Promise Clipboard content or empty string if all attempts fail - */ -const readClipboardWithRetry = async ( - maxRetries = 3, - delayMs = 100, -): Promise => { - for (let i = 0; i < maxRetries; i++) { - try { - return { data: await navigator.clipboard.readText() } - } catch (error) { - console.warn( - `Retry to read clipboard (attempt ${i + 1}/${maxRetries}):`, - error, - ) - if (i === maxRetries - 1) { - return { data: undefined, err: `Retry limit exceeded. [${maxRetries}]` } - } - await new Promise((resolve) => setTimeout(resolve, delayMs)) - } - } - return { data: undefined, err: "Out of retries." } -} - -const port = chrome.runtime.connect({ name: "clipboard" }) -port.onDisconnect.addListener(function (port) { - console.warn("onDisconnect", port.name) -}) - -readClipboardWithRetry() - .then((ret: ClipboardResult) => { - port.postMessage({ command: ServiceWorkerCommand.setClipboard, data: ret }) - }) - .catch((error: Error) => { - port.postMessage({ - command: ServiceWorkerCommand.setClipboard, - data: { data: undefined, err: error.message }, - }) - }) - .finally(() => { - port.disconnect() - }) diff --git a/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts new file mode 100644 index 000000000..b3f3458b0 --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openPopupWindow } from "../chrome" +import { readOffscreenClipboard } from "../offscreenClipboard" +import { Ipc } from "../ipc" +import { POPUP_TYPE, WINDOW_STATE } from "@/const" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) +vi.mock("../screen", () => ({ + getScreenSize: vi + .fn() + .mockResolvedValue({ left: 0, top: 0, width: 1280, height: 800 }), +})) +vi.mock("../windowStackManager", () => ({ + WindowStackManager: { addWindows: vi.fn().mockResolvedValue(undefined) }, +})) + +const params = { + commandId: "test", + url: { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, + }, + type: POPUP_TYPE.POPUP, + top: 10, + left: 20, + width: 600, + height: 700, +} + +describe("openPopupWindow", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + getCurrent: vi.fn().mockResolvedValue({ id: 1, incognito: true }), + create: vi.fn().mockResolvedValue({ id: 2, tabs: [{ id: 20 }] }), + update: vi.fn().mockResolvedValue(undefined), + }, + declarativeNetRequest: { + ...chrome.declarativeNetRequest, + RuleActionType: { MODIFY_HEADERS: "modifyHeaders" }, + HeaderOperation: { REMOVE: "remove", SET: "set" }, + ResourceType: { MAIN_FRAME: "main_frame" }, + getSessionRules: vi.fn().mockResolvedValue([]), + updateSessionRules: vi.fn().mockResolvedValue(undefined), + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("COP-01: 正常系: 完成したURLで一度だけ作成し位置・サイズ・シークレット設定を保持する", async () => { + expect(await openPopupWindow(params)).toEqual({ + tabId: 20, + clipboardText: "clipboard text", + }) + expect(chrome.windows.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + type: "popup", + top: 10, + left: 20, + width: 600, + height: 700, + state: undefined, + incognito: true, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + + it("COP-02: 正常系: fullscreenでは座標とサイズを渡さず作成後にも状態を適用する", async () => { + await openPopupWindow({ ...params, windowState: WINDOW_STATE.FULLSCREEN }) + expect(chrome.windows.create).toHaveBeenCalledWith( + expect.objectContaining({ + state: "fullscreen", + top: undefined, + left: undefined, + width: undefined, + height: undefined, + }), + ) + expect(chrome.windows.update).toHaveBeenCalledWith(2, { + state: "fullscreen", + }) + }) + + it("COP-03: 異常系: 読み取り失敗時にも表示先を作成してエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Read failed", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openPopupWindow(params) + expect(chrome.windows.create).toHaveBeenCalledOnce() + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/chrome.openTab.test.ts b/packages/extension/src/services/__tests__/chrome.openTab.test.ts index df487358f..4c9ec333a 100644 --- a/packages/extension/src/services/__tests__/chrome.openTab.test.ts +++ b/packages/extension/src/services/__tests__/chrome.openTab.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { openTab } from "../chrome" -import { Ipc, ServiceWorkerCommand } from "../ipc" +import { Ipc } from "../ipc" +import { readOffscreenClipboard } from "../offscreenClipboard" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) const sourceTab = { id: 10, windowId: 1, index: 2 } as chrome.tabs.Tab const resultTab = { id: 20, windowId: 1 } as chrome.tabs.Tab @@ -10,26 +13,6 @@ const url = { selectionFromClipboard: true, } -function provideClipboard(data: string, err?: string) { - vi.mocked(chrome.runtime.onConnect.addListener).mockImplementation( - (listener) => { - const port = { - sender: { tab: { id: 30 } }, - onMessage: { - addListener: (onMessage: (message: unknown) => void) => { - onMessage({ - command: ServiceWorkerCommand.setClipboard, - data: { data, err }, - }) - }, - removeListener: vi.fn(), - }, - } as unknown as chrome.runtime.Port - listener(port) - }, - ) -} - describe("openTab", () => { afterEach(() => vi.unstubAllGlobals()) beforeEach(() => { @@ -48,14 +31,16 @@ describe("openTab", () => { remove: vi.fn().mockResolvedValue(undefined), }, }) - provideClipboard("clipboard text") + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) }) - it("COT-01: 正常系: クリップボードを一時ウィンドウで読み非アクティブタブに渡す", async () => { + it("COT-01: 正常系: Offscreenで読み非アクティブタブを直接作成する", async () => { const result = await openTab({ url, active: false }) - expect(chrome.windows.create).toHaveBeenCalledOnce() - expect(chrome.windows.remove).toHaveBeenCalledWith(3) + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.windows.create).not.toHaveBeenCalled() expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith({ url: "https://example.com/?q=clipboard+text", active: false, @@ -66,12 +51,25 @@ describe("openTab", () => { expect(result).toEqual({ tabId: 20, clipboardText: "clipboard text" }) }) + it("COT-01-a: 正常系: アクティブタブも完成したURLで一度だけ作成する", async () => { + await openTab({ url, active: true }) + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + url: "https://example.com/?q=clipboard+text", + active: true, + }), + ) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + it("COT-02: 正常系: クリップボード不要なら一時ウィンドウを開かない", async () => { await openTab({ url: { ...url, selectionText: "selected", selectionFromClipboard: false }, active: false, }) + expect(readOffscreenClipboard).not.toHaveBeenCalled() expect(chrome.windows.create).not.toHaveBeenCalled() expect(chrome.tabs.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -82,7 +80,10 @@ describe("openTab", () => { }) it("COT-03: 異常系: 読み取り失敗時も非アクティブで開きエラーを通知する", async () => { - provideClipboard("", "Permission denied") + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) try { @@ -92,7 +93,7 @@ describe("openTab", () => { ) expect(connection).toHaveBeenCalledWith(20) expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) - expect(chrome.windows.remove).toHaveBeenCalledWith(3) + expect(chrome.windows.create).not.toHaveBeenCalled() expect(result).toEqual({ tabId: 20, clipboardText: "" }) } finally { connection.mockRestore() diff --git a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts index b8826ffc9..8ece136aa 100644 --- a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts +++ b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { readOffscreenClipboard } from "../offscreenClipboard" const createDocument = vi.fn() -const closeDocument = vi.fn() const getContexts = vi.fn() const sendMessage = vi.fn() @@ -10,8 +9,11 @@ describe("readOffscreenClipboard", () => { beforeEach(() => { vi.resetAllMocks() createDocument.mockResolvedValue(undefined) - closeDocument.mockResolvedValue(undefined) - getContexts.mockResolvedValue([]) + let documentExists = false + getContexts.mockImplementation(async () => (documentExists ? [{}] : [])) + createDocument.mockImplementation(async () => { + documentExists = true + }) sendMessage.mockResolvedValue({ data: "日本語 clipboard" }) vi.stubGlobal("chrome", { ...chrome, @@ -24,14 +26,13 @@ describe("readOffscreenClipboard", () => { }, offscreen: { createDocument, - closeDocument, Reason: { CLIPBOARD: "CLIPBOARD" }, }, }) }) afterEach(() => vi.unstubAllGlobals()) - it("OC-01: 正常系: 非表示ドキュメントで読み取り後に閉じる", async () => { + it("OC-01: 正常系: 非表示ドキュメントを再利用し毎回最新の文字列を返す", async () => { await expect(readOffscreenClipboard()).resolves.toEqual({ data: "日本語 clipboard", }) @@ -41,45 +42,58 @@ describe("readOffscreenClipboard", () => { reasons: ["CLIPBOARD"], }), ) - expect(closeDocument).toHaveBeenCalledOnce() + sendMessage.mockResolvedValue({ data: "updated" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ data: "updated" }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledTimes(2) }) it("OC-02: 正常系: 再起動後に残ったドキュメントを再利用する", async () => { getContexts.mockResolvedValue([{}]) await readOffscreenClipboard() expect(createDocument).not.toHaveBeenCalled() - expect(closeDocument).toHaveBeenCalledOnce() }) - it("OC-03: 異常系: 読み取り失敗時も閉じてエラーを返す", async () => { + it("OC-03: 異常系: 読み取り失敗後も次の要求で読み取れる", async () => { sendMessage.mockRejectedValue(new Error("Read failed")) await expect(readOffscreenClipboard()).resolves.toEqual({ data: "", err: "Read failed", }) - expect(closeDocument).toHaveBeenCalledOnce() + sendMessage.mockResolvedValue({ data: "recovered" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "recovered", + }) + expect(createDocument).toHaveBeenCalledOnce() }) - it("OC-04: 正常系: 同時要求では前のドキュメントを閉じてから次を作る", async () => { - const events: string[] = [] - createDocument.mockImplementation(async () => { - events.push("create") - }) - sendMessage.mockImplementation(async () => { - events.push("read") - return { data: "text" } + it("OC-04: 正常系: 同時要求でも一度だけ作成して異なる値を順に返す", async () => { + sendMessage + .mockResolvedValueOnce({ data: "first" }) + .mockResolvedValueOnce({ data: "second" }) + const results = await Promise.all([ + readOffscreenClipboard(), + readOffscreenClipboard(), + ]) + expect(results).toEqual([{ data: "first" }, { data: "second" }]) + expect(createDocument).toHaveBeenCalledOnce() + }) + + it("OC-05: 正常系: ドキュメントが破棄されたら再作成する", async () => { + await readOffscreenClipboard() + getContexts.mockResolvedValueOnce([]) + await readOffscreenClipboard() + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-06: 異常系: 作成失敗後の要求で再試行できる", async () => { + createDocument.mockRejectedValueOnce(new Error("Creation failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Creation failed", }) - closeDocument.mockImplementation(async () => { - events.push("close") + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", }) - await Promise.all([readOffscreenClipboard(), readOffscreenClipboard()]) - expect(events).toEqual([ - "create", - "read", - "close", - "create", - "read", - "close", - ]) }) }) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 5911468c7..98c124ada 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -6,11 +6,7 @@ import { POPUP_OFFSET, POPUP_TYPE, WINDOW_STATE } from "@/const" import { PopupOption } from "@/services/option/defaultSettings" import { ServiceWorkerData } from "@/services/serviceWorkerData" import { WindowStackManager } from "@/services/windowStackManager" -import { - ServiceWorkerCommand, - ClipboardResult, - TabCommand, -} from "@/services/ipc" +import { TabCommand } from "@/services/ipc" import { getScreenSize } from "@/services/screen" import { Ipc } from "@/services/ipc" import { t } from "@/services/i18n" @@ -136,17 +132,6 @@ export type OpenTabProps = { active: boolean } -type ReadClipboardParam = Omit & { - incognito: boolean - state?: "fullscreen" | "maximized" -} - -type ReadClipboardResult = { - clipboardText: string - window: chrome.windows.Window - err?: string -} - type OpenResult = { tabId: number | undefined clipboardText: string @@ -340,106 +325,13 @@ const updateRules = async (tabIds: number[]) => { }) } -/** - * Read clipboard content from a tab - * @param {number} tabId - The ID of the tab to read clipboard from - * @returns {Promise} The clipboard content - */ -const readClipboardContent = async ( - tabId: number, -): Promise => { - try { - const result = await new Promise((resolve) => { - const onConnect = (port: chrome.runtime.Port) => { - if (port.sender?.tab?.id !== tabId) { - return - } - const onMessage = (msg: { command: string; data: ClipboardResult }) => { - if (msg.command === ServiceWorkerCommand.setClipboard) { - port.onMessage.removeListener(onMessage) - chrome.runtime.onConnect.removeListener(onConnect) - resolve(msg.data) - } - } - port.onMessage.addListener(onMessage) - } - chrome.runtime.onConnect.addListener(onConnect) - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } - }) - - return result - } catch (e) { - console.error(e) - return { data: "", err: e instanceof Error ? e.message : "Unknown error" } - } -} - -const openWindowAndReadClipboard = async ( - param: ReadClipboardParam, -): Promise => { - const usesWindowState = - param.state === "fullscreen" || param.state === "maximized" - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: param.type, - width: usesWindowState ? undefined : param.width, - height: usesWindowState ? undefined : param.height, - left: usesWindowState ? undefined : param.left, - top: usesWindowState ? undefined : param.top, - state: param.state, - incognito: param.incognito, - }) - - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - - return { - window: w, - clipboardText: result.data ?? "", - err: result.err, - } -} - -/** Read clipboard text without a visible window when Offscreen API is available. */ +/** Read clipboard text through the shared offscreen document. */ export const readClipboard = async (): Promise<{ clipboardText: string err?: string }> => { - if ( - typeof chrome.offscreen?.createDocument === "function" && - typeof chrome.runtime.getContexts === "function" - ) { - const result = await readOffscreenClipboard() - return { clipboardText: result.data ?? "", err: result.err } - } - - // Preserve compatibility with browsers without the Offscreen API. - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: "popup", - width: 1, - height: 1, - left: 0, - top: 0, - state: "normal", - }) - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - await closeWindow(w.id, "readClipboard close window") - return { - clipboardText: result.data ?? "", - err: result.err, - } + const result = await readOffscreenClipboard() + return { clipboardText: result.data ?? "", err: result.err } } export const openPopupWindow = async ( @@ -474,68 +366,39 @@ export const openPopupWindow = async ( ? "maximized" : undefined - let window: chrome.windows.Window - let clipboardText = "" - - if (needsClipboard(url)) { - const result = await openWindowAndReadClipboard({ - commandId: param.commandId, - type, - width, - height, - top: at, - left: al, - state: windowState, - incognito: current.incognito, - }) - window = result.window - clipboardText = result.clipboardText - - try { - await chrome.tabs.update(window.tabs?.[0].id as number, { - url: toUrl(url, clipboardText), - }) - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) + const clipboard = needsClipboard(url) + ? await readClipboard() + : { clipboardText: "", err: undefined } + const window = (await chrome.windows.create({ + url: toUrl(url, clipboard.clipboardText), + type, + width: usesWindowState ? undefined : width, + height: usesWindowState ? undefined : height, + top: usesWindowState ? undefined : at, + left: usesWindowState ? undefined : al, + state: windowState, + incognito: current.incognito, + }))! + try { + if (isFullscreen) { + // macOS may require reapplying fullscreen after window creation. + await chrome.windows.update(window.id!, { state: "fullscreen" }) } + } catch (e) { + console.error(e) + } - if (result.err) { - await Ipc.ensureConnection(window.tabs?.[0].id as number) - await Ipc.sendTab( - window.tabs?.[0].id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } - } else { - window = (await chrome.windows.create({ - url: toUrl(url), - type, - width: usesWindowState ? undefined : width, - height: usesWindowState ? undefined : height, - top: usesWindowState ? undefined : at, - left: usesWindowState ? undefined : al, - state: windowState, - incognito: current.incognito, - }))! - try { - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) - } + if (clipboard.err) { + await Ipc.ensureConnection(window.tabs?.[0].id as number) + await Ipc.sendTab( + window.tabs?.[0].id as number, + TabCommand.showToast, + { + title: t("clipboard_error_title"), + description: t("clipboard_error_description"), + action: t("clipboard_error_action"), + }, + ) } await updateServiceWorkerData([window], param.commandId, current.id, type) @@ -545,7 +408,7 @@ export const openPopupWindow = async ( return { tabId: window.tabs?.[0]?.id, - clipboardText, + clipboardText: clipboard.clipboardText, } } @@ -609,36 +472,17 @@ export const openTab = async (param: OpenTabProps): Promise => { console.warn("Failed to get current tab:", e) } - const clipboardRequired = needsClipboard(url) - let tab: chrome.tabs.Tab - let result: ClipboardResult = { data: "" } - - if (clipboardRequired && active) { - tab = await chrome.tabs.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - active: true, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - result = await readClipboardContent(tab.id as number) - await chrome.tabs.update(tab.id as number, { - url: toUrl(url, result.data ?? ""), - }) - } else { - if (clipboardRequired) { - // Read separately so the destination tab stays inactive. - const clipboard = await readClipboard() - result = { data: clipboard.clipboardText, err: clipboard.err } - } - tab = await chrome.tabs.create({ - url: toUrl(url, result.data ?? ""), - active, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - } + const clipboard = needsClipboard(url) + ? await readClipboard() + : { clipboardText: "", err: undefined } + const tab = await chrome.tabs.create({ + url: toUrl(url, clipboard.clipboardText), + active, + windowId: currentTab?.windowId, + index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, + }) - if (result.err) { + if (clipboard.err) { await Ipc.ensureConnection(tab.id as number) await Ipc.sendTab(tab.id as number, TabCommand.showToast, { title: t("clipboard_error_title"), @@ -649,7 +493,7 @@ export const openTab = async (param: OpenTabProps): Promise => { return { tabId: tab.id as number, - clipboardText: result.data ?? "", + clipboardText: clipboard.clipboardText, } } diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index d30e6d4f8..8cb5e4145 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -62,7 +62,6 @@ export enum ServiceWorkerCommand { toggleStar = "toggleStar", getTabId = "getTabId", getActiveTabId = "getActiveTabId", - setClipboard = "setClipboard", closeTab = "closeTab", resolveIconColors = "resolveIconColors", // Hub diff --git a/packages/extension/src/services/offscreenClipboard.ts b/packages/extension/src/services/offscreenClipboard.ts index ebfdc781e..c0f04e855 100644 --- a/packages/extension/src/services/offscreenClipboard.ts +++ b/packages/extension/src/services/offscreenClipboard.ts @@ -4,12 +4,12 @@ let pendingRead: Promise = Promise.resolve() async function readOnce(): Promise { const documentUrl = chrome.runtime.getURL("src/offscreen_clipboard.html") - let documentReady = false try { const contexts = await chrome.runtime.getContexts({ contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], documentUrls: [documentUrl], }) + // Recheck after worker restarts or if Chrome discarded the document. if (contexts.length === 0) { await chrome.offscreen.createDocument({ url: "src/offscreen_clipboard.html", @@ -17,7 +17,6 @@ async function readOnce(): Promise { justification: "Read clipboard text without opening a visible window", }) } - documentReady = true return await chrome.runtime.sendMessage({ target: "offscreen-clipboard", command: "read", @@ -27,17 +26,11 @@ async function readOnce(): Promise { data: "", err: error instanceof Error ? error.message : String(error), } - } finally { - if (documentReady) { - await chrome.offscreen.closeDocument().catch((error) => { - console.warn("Failed to close clipboard document:", error) - }) - } } } export function readOffscreenClipboard(): Promise { - // Only one offscreen document may exist. Serialize its complete lifecycle. + // Serialize reads and creation; keep the document for subsequent requests. const read = pendingRead.then(readOnce) pendingRead = read.then( () => undefined, diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 0f65c1e25..78846ea11 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -171,7 +171,6 @@ export default defineConfig(({ mode }) => { emptyOutDir: !isWatchMode, // prevent deleting the dist folder when running in watch mode rollupOptions: { input: { - clipboard: "src/clipboard.html", offscreenClipboard: "src/offscreen_clipboard.html", onboarding: "src/onboarding_page.html", }, From 632670ab472d2ddf5a0e8e877910a1294f458d9f Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 13:25:52 +0900 Subject: [PATCH 30/84] =?UTF-8?q?fix:=20Offscreen=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=9C=E3=83=BC=E3=83=89=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E3=81=AE=E5=A4=B1=E6=95=97=E6=99=82=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=82=92=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 読み取り失敗時に最大3回まで再試行する - Offscreenからの不正な応答をエラーとして扱う - textareaにフォーカスできない場合はエラーにする - 作成時の単一ドキュメント制約エラーでは既存ドキュメントを使う - バックグラウンドタブで開く場合は実行元タブへエラーを通知する - エラートーストとクリップボード読み取りの重複処理を共通化する Co-Authored-By: Claude Opus 5.5 --- docs/design/clipboard.md | 10 +++ packages/extension/src/offscreen_clipboard.ts | 4 + .../services/__tests__/chrome.openTab.test.ts | 23 +++++- .../__tests__/offscreenClipboard.test.ts | 39 ++++++++- packages/extension/src/services/chrome.ts | 46 +++++------ .../src/services/offscreenClipboard.ts | 80 +++++++++++++------ .../src/services/pageAction/serviceWorker.ts | 9 +-- 7 files changed, 152 insertions(+), 59 deletions(-) diff --git a/docs/design/clipboard.md b/docs/design/clipboard.md index eae5b4176..38d69afc4 100644 --- a/docs/design/clipboard.md +++ b/docs/design/clipboard.md @@ -12,6 +12,16 @@ Chrome 116相当以降を対象とし、Service Workerからの読み取りは - Service Worker再起動時も `runtime.getContexts()` で既存ドキュメントを検出する。 - Chromeによってドキュメントが破棄された場合は、次の要求で再作成する。 - textareaの内容は読み取り直後に消去し、クリップボード本文を保持しない。 +- 作成時に他のコンテキストが先に作成していた場合(単一ドキュメント制約のエラー)は、 + 既存ドキュメントをそのまま使用する。 + +## 失敗時の扱い + +- 読み取りに失敗した場合は50ms間隔で最大3回まで再試行する。 +- Offscreen Documentからの応答が `ClipboardResult` の形式でない場合はエラーとして扱う。 +- textareaにフォーカスできない場合は、空文字を成功として返さずエラーとする。 +- エラー時のトーストは、ユーザーが見ているタブへ表示する。 + バックグラウンドタブで開く場合は、表示先ではなく実行元のタブへ通知する。 Offscreen Documentを表示先のタブ・ウィンドウへ転用するAPIはないため、 従来の「読み取り画面を表示先として転用する」最適化はそのまま移植できない。 diff --git a/packages/extension/src/offscreen_clipboard.ts b/packages/extension/src/offscreen_clipboard.ts index 1db0aef54..1a6a7fc9a 100644 --- a/packages/extension/src/offscreen_clipboard.ts +++ b/packages/extension/src/offscreen_clipboard.ts @@ -15,6 +15,10 @@ chrome.runtime.onMessage.addListener((message, sender, respond) => { textarea.value = "" textarea.focus() textarea.select() + // Without focus, paste may report success while inserting nothing. + if (document.activeElement !== textarea) { + throw new Error("Failed to focus the clipboard textarea") + } // Async Clipboard requires document focus, which offscreen documents lack. if (!document.execCommand("paste")) { throw new Error("Failed to paste clipboard text") diff --git a/packages/extension/src/services/__tests__/chrome.openTab.test.ts b/packages/extension/src/services/__tests__/chrome.openTab.test.ts index 4c9ec333a..de10216f4 100644 --- a/packages/extension/src/services/__tests__/chrome.openTab.test.ts +++ b/packages/extension/src/services/__tests__/chrome.openTab.test.ts @@ -79,7 +79,7 @@ describe("openTab", () => { ) }) - it("COT-03: 異常系: 読み取り失敗時も非アクティブで開きエラーを通知する", async () => { + it("COT-03: 異常系: 読み取り失敗時は非アクティブで開き元のタブへエラーを通知する", async () => { vi.mocked(readOffscreenClipboard).mockResolvedValue({ data: "", err: "Permission denied", @@ -91,8 +91,8 @@ describe("openTab", () => { expect(chrome.tabs.create).toHaveBeenCalledWith( expect.objectContaining({ active: false }), ) - expect(connection).toHaveBeenCalledWith(20) - expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + expect(connection).toHaveBeenCalledWith(10) + expect(sendTab).toHaveBeenCalledWith(10, "showToast", expect.any(Object)) expect(chrome.windows.create).not.toHaveBeenCalled() expect(result).toEqual({ tabId: 20, clipboardText: "" }) } finally { @@ -100,4 +100,21 @@ describe("openTab", () => { sendTab.mockRestore() } }) + + it("COT-04: 異常系: アクティブタブで開いた場合は新しいタブへエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openTab({ url, active: true }) + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) }) diff --git a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts index 8ece136aa..fbaf9d80d 100644 --- a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts +++ b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts @@ -86,14 +86,49 @@ describe("readOffscreenClipboard", () => { expect(createDocument).toHaveBeenCalledTimes(2) }) - it("OC-06: 異常系: 作成失敗後の要求で再試行できる", async () => { + it("OC-06: 異常系: 作成に一時失敗しても再試行して読み取る", async () => { createDocument.mockRejectedValueOnce(new Error("Creation failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-07: 異常系: 一時的な読み取り失敗は再試行して読み取る", async () => { + sendMessage + .mockResolvedValueOnce({ data: "", err: "Busy" }) + .mockResolvedValueOnce({ data: "retried" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "retried", + }) + expect(sendMessage).toHaveBeenCalledTimes(2) + }) + + it("OC-08: 異常系: 再試行上限に達したら最後のエラーを返す", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) await expect(readOffscreenClipboard()).resolves.toEqual({ data: "", - err: "Creation failed", + err: "Read failed", }) + expect(sendMessage).toHaveBeenCalledTimes(3) + }) + + it("OC-09: 異常系: 不正な応答はエラーとして扱う", async () => { + sendMessage.mockResolvedValue(undefined) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Invalid response from offscreen document", + }) + }) + + it("OC-10: 異常系: 作成時の競合で既存ドキュメントがあればそのまま読み取る", async () => { + createDocument.mockRejectedValueOnce( + new Error("Only a single offscreen document may be created."), + ) await expect(readOffscreenClipboard()).resolves.toEqual({ data: "日本語 clipboard", }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledOnce() }) }) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 98c124ada..5f764efa1 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -1,4 +1,3 @@ -import { readOffscreenClipboard } from "./offscreenClipboard" import { sleep, toUrl, isOverBytes, needsClipboard } from "@/lib/utils" import type { ScreenSize } from "@/services/dom" import type { ShowToastParam, UrlParam, WindowLayer } from "@/types" @@ -8,6 +7,7 @@ import { ServiceWorkerData } from "@/services/serviceWorkerData" import { WindowStackManager } from "@/services/windowStackManager" import { TabCommand } from "@/services/ipc" import { getScreenSize } from "@/services/screen" +import { readOffscreenClipboard } from "@/services/offscreenClipboard" import { Ipc } from "@/services/ipc" import { t } from "@/services/i18n" import { @@ -334,6 +334,22 @@ export const readClipboard = async (): Promise<{ return { clipboardText: result.data ?? "", err: result.err } } +/** Read clipboard text only when the URL refers to the clipboard. */ +const readClipboardIfNeeded = async ( + url: string | UrlParam, +): Promise<{ clipboardText: string; err?: string }> => + needsClipboard(url) ? readClipboard() : { clipboardText: "" } + +/** Notify the user in the given tab that reading the clipboard failed. */ +export const showClipboardErrorToast = async (tabId: number): Promise => { + await Ipc.ensureConnection(tabId) + await Ipc.sendTab(tabId, TabCommand.showToast, { + title: t("clipboard_error_title"), + description: t("clipboard_error_description"), + action: t("clipboard_error_action"), + }) +} + export const openPopupWindow = async ( param: OpenPopupProps, ): Promise => { @@ -366,9 +382,7 @@ export const openPopupWindow = async ( ? "maximized" : undefined - const clipboard = needsClipboard(url) - ? await readClipboard() - : { clipboardText: "", err: undefined } + const clipboard = await readClipboardIfNeeded(url) const window = (await chrome.windows.create({ url: toUrl(url, clipboard.clipboardText), type, @@ -389,16 +403,7 @@ export const openPopupWindow = async ( } if (clipboard.err) { - await Ipc.ensureConnection(window.tabs?.[0].id as number) - await Ipc.sendTab( - window.tabs?.[0].id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) + await showClipboardErrorToast(window.tabs?.[0].id as number) } await updateServiceWorkerData([window], param.commandId, current.id, type) @@ -472,9 +477,7 @@ export const openTab = async (param: OpenTabProps): Promise => { console.warn("Failed to get current tab:", e) } - const clipboard = needsClipboard(url) - ? await readClipboard() - : { clipboardText: "", err: undefined } + const clipboard = await readClipboardIfNeeded(url) const tab = await chrome.tabs.create({ url: toUrl(url, clipboard.clipboardText), active, @@ -483,12 +486,9 @@ export const openTab = async (param: OpenTabProps): Promise => { }) if (clipboard.err) { - await Ipc.ensureConnection(tab.id as number) - await Ipc.sendTab(tab.id as number, TabCommand.showToast, { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }) + // A background tab is not visible, so notify in the tab the user is on. + const toastTabId = active ? tab.id : (currentTab?.id ?? tab.id) + await showClipboardErrorToast(toastTabId as number) } return { diff --git a/packages/extension/src/services/offscreenClipboard.ts b/packages/extension/src/services/offscreenClipboard.ts index c0f04e855..096265f8e 100644 --- a/packages/extension/src/services/offscreenClipboard.ts +++ b/packages/extension/src/services/offscreenClipboard.ts @@ -1,40 +1,72 @@ import type { ClipboardResult } from "./ipc" +import { sleep } from "@/lib/utils" -let pendingRead: Promise = Promise.resolve() +const DOCUMENT_PATH = "src/offscreen_clipboard.html" +const MAX_ATTEMPTS = 3 +const RETRY_DELAY_MS = 50 -async function readOnce(): Promise { - const documentUrl = chrome.runtime.getURL("src/offscreen_clipboard.html") +let pendingRead: Promise = Promise.resolve() + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const isClipboardResult = (value: unknown): value is ClipboardResult => + typeof value === "object" && + value !== null && + typeof (value as ClipboardResult).data === "string" + +async function ensureDocument(): Promise { + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [chrome.runtime.getURL(DOCUMENT_PATH)], + }) + // Recheck after worker restarts or if Chrome discarded the document. + if (contexts.length > 0) return try { - const contexts = await chrome.runtime.getContexts({ - contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], - documentUrls: [documentUrl], + await chrome.offscreen.createDocument({ + url: DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.CLIPBOARD], + justification: "Read clipboard text without opening a visible window", }) - // Recheck after worker restarts or if Chrome discarded the document. - if (contexts.length === 0) { - await chrome.offscreen.createDocument({ - url: "src/offscreen_clipboard.html", - reasons: [chrome.offscreen.Reason.CLIPBOARD], - justification: "Read clipboard text without opening a visible window", - }) - } - return await chrome.runtime.sendMessage({ + } catch (error) { + // Another context may have created it between the check and the creation. + if (!/single offscreen document/i.test(toErrorMessage(error))) throw error + } +} + +async function readOnce(): Promise { + try { + await ensureDocument() + const result: unknown = await chrome.runtime.sendMessage({ target: "offscreen-clipboard", command: "read", }) - } catch (error) { - return { - data: "", - err: error instanceof Error ? error.message : String(error), + if (!isClipboardResult(result)) { + return { data: "", err: "Invalid response from offscreen document" } } + return result + } catch (error) { + return { data: "", err: toErrorMessage(error) } + } +} + +async function readWithRetry(): Promise { + let result: ClipboardResult = { data: "" } + for (let i = 0; i < MAX_ATTEMPTS; i++) { + result = await readOnce() + if (!result.err) return result + console.warn( + `Retry to read clipboard (attempt ${i + 1}/${MAX_ATTEMPTS}):`, + result.err, + ) + if (i < MAX_ATTEMPTS - 1) await sleep(RETRY_DELAY_MS) } + return result } export function readOffscreenClipboard(): Promise { // Serialize reads and creation; keep the document for subsequent requests. - const read = pendingRead.then(readOnce) - pendingRead = read.then( - () => undefined, - () => undefined, - ) + const read = pendingRead.then(readWithRetry) + pendingRead = read.catch(() => undefined) return read } diff --git a/packages/extension/src/services/pageAction/serviceWorker.ts b/packages/extension/src/services/pageAction/serviceWorker.ts index 778abbe58..3383abea4 100644 --- a/packages/extension/src/services/pageAction/serviceWorker.ts +++ b/packages/extension/src/services/pageAction/serviceWorker.ts @@ -15,6 +15,7 @@ import { getCurrentTab, OpenPopupProps, readClipboard, + showClipboardErrorToast, } from "@/services/chrome" import { incrementCommandExecutionCount } from "@/services/commandMetrics" import { @@ -40,9 +41,7 @@ import type { PageActionContext, PopupOption, DeepPartial, - ShowToastParam, } from "@/types" -import { t } from "@/services/i18n" import { ServiceWorkerData } from "@/services/serviceWorkerData" ServiceWorkerData.init() @@ -386,11 +385,7 @@ export const openAndRun = ( const clipboard = await readClipboard() clipboardText = clipboard.clipboardText if (clipboard.err) { - await Ipc.sendTab(tabId, TabCommand.showToast, { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }) + await showClipboardErrorToast(tabId) } } } else { From 261cde6ddbb851ed4a043535876f065c984d02fd Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 13:33:59 +0900 Subject: [PATCH 31/84] =?UTF-8?q?test:=20E2E=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E5=90=8D=E3=81=A8=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=8B=B1=E8=AA=9E=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- packages/extension/e2e/link-preview.spec.ts | 2 +- packages/extension/e2e/shortcut.spec.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/extension/e2e/link-preview.spec.ts b/packages/extension/e2e/link-preview.spec.ts index 237dc1024..177dc688d 100644 --- a/packages/extension/e2e/link-preview.spec.ts +++ b/packages/extension/e2e/link-preview.spec.ts @@ -30,7 +30,7 @@ test.describe("Link Preview", () => { const href = await link.getAttribute("href") expect(href).toBeTruthy() - // React 側が ready になってからネイティブ Shift+click + // Native Shift+click after the React side is ready const [previewPage] = await Promise.all([ context.waitForEvent("page", { timeout: 5000 }), await link.click({ diff --git a/packages/extension/e2e/shortcut.spec.ts b/packages/extension/e2e/shortcut.spec.ts index dc994f5e1..c347578f8 100644 --- a/packages/extension/e2e/shortcut.spec.ts +++ b/packages/extension/e2e/shortcut.spec.ts @@ -27,7 +27,7 @@ async function expectSearch(context: BrowserContext, text: string) { ) } -test.describe("検索ショートカット", () => { +test.describe("Search shortcuts", () => { test.beforeEach(async ({ context, extensionId, getCommands }) => { const options = new OptionsPage(context, extensionId, getCommands) await options.importSettings({ @@ -50,7 +50,7 @@ test.describe("検索ショートカット", () => { await options.close() }) - test("E2E-100: slot_1で検索コマンドが実行され選択テキストが渡る", async ({ + test("E2E-100: slot_1 runs the search command with the selected text", async ({ context, page, }) => { @@ -65,7 +65,7 @@ test.describe("検索ショートカット", () => { await expectSearch(context, selection) }) - test("E2E-101: 選択テキストの日本語・空白・記号が検索URLへ渡る", async ({ + test("E2E-101: Japanese, spaces, and symbols in the selected text are passed to the search URL", async ({ context, page, }) => { @@ -86,7 +86,7 @@ test.describe("検索ショートカット", () => { await expectSearch(context, text) }) - test("E2E-102: 未選択時にクリップボードから検索する", async ({ + test("E2E-102: Searches with the clipboard when nothing is selected", async ({ context, page, }) => { @@ -101,7 +101,7 @@ test.describe("検索ショートカット", () => { await expectSearch(context, text) }) - test("E2E-103: DO_NOTHING設定では未選択時に実行しない", async ({ + test("E2E-103: Does nothing when nothing is selected with the DO_NOTHING setting", async ({ context, page, }) => { @@ -122,7 +122,7 @@ test.describe("検索ショートカット", () => { ) }) - test("E2E-104: Content Scriptのないchrome://ページではService Workerから実行する", async ({ + test("E2E-104: Runs from the Service Worker on chrome:// pages without a content script", async ({ context, page, }) => { @@ -135,7 +135,7 @@ test.describe("検索ショートカット", () => { await expectSearch(context, text) }) - test("E2E-105: chrome.commands.getAllでslot_1〜slot_3が登録されている", async ({ + test("E2E-105: slot_1 to slot_3 are registered in chrome.commands.getAll", async ({ context, }) => { const commands = await context @@ -187,7 +187,7 @@ const pageActionCases = [ ] as const for (const { id, openMode, variable, hasSelection } of pageActionCases) { - test(`${id}: PageActionショートカット / ${openMode} / {{${variable}}} / ${hasSelection ? "選択あり" : "未選択"}`, async ({ + test(`${id}: PageAction shortcut / ${openMode} / {{${variable}}} / ${hasSelection ? "with selection" : "no selection"}`, async ({ context, extensionId, getCommands, From 92bc5438bc257bb9192f16c362f6d99f42b255d7 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 13:57:20 +0900 Subject: [PATCH 32/84] fix(extension): address review findings on menu hover and shadow DOM properties - Remove the unlayered `background: none` from the menu button so that layered utilities such as `hover:bg-accent` take effect. - Reset `@property` custom properties without an initial value to `initial` inside the shadow root, so values from ancestors or the host page are not inherited. Co-Authored-By: Claude Opus 5.5 --- docs/design/tailwindcss-v4-migration.md | 1 + packages/extension/AGENTS.md | 2 ++ .../src/components/menu/Menu.module.css | 12 ++++++-- .../postcss-shadow-dom-properties.test.ts | 28 +++++++++++++++++-- .../src/lib/postcss-shadow-dom-properties.ts | 11 ++++---- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/design/tailwindcss-v4-migration.md b/docs/design/tailwindcss-v4-migration.md index f3125f073..da5e10ea5 100644 --- a/docs/design/tailwindcss-v4-migration.md +++ b/docs/design/tailwindcss-v4-migration.md @@ -99,6 +99,7 @@ content script の UI は Shadow DOM 内でスタイルを適用している。v **対策: 自前のビルドプラグインで初期値を Shadow root 内に閉じて付与する。** - ビルド時に生成 CSS の `@property --tw-*` 定義から `initial-value` を抽出し、`:host, *, ::before, ::after, ::backdrop { --tw-xxx: ; }` を `@layer base`(`@layer properties`)に付与する小さな PostCSS / Vite プラグインを作成する。 +- `initial-value` を持たない `@property` も `initial` として宣言する。Shadow DOM 内では未登録のカスタムプロパティとして扱われ常に継承されるため、宣言しないと祖先要素(ユーザーページ側を含む)の値が流入する(例: 親の `duration-300` が子の `transition-all` に伝わる)。 - 付与したルールは Shadow root に注入される CSS の中にのみ存在するため、ユーザーページへは影響しない。 - `@property` を `document.head` に注入する方式は、ユーザーページにグローバルな登録を残して影響を与えるため**採用しない**。逆に、ユーザーページ側が同名の `--tw-*` を `@property` 登録している場合(ユーザーページが Tailwind v4 製の場合など)でも、各要素に明示的に値を宣言するため初期値の差異の影響を受けない。 - ただし `@property` の `syntax`(型)はユーザーページ側の登録が Shadow DOM 内にも適用される。Tailwind v4 同士であれば同一定義のため問題ないが、型の異なる同名登録がある場合の挙動を PoC で確認する。 diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index 83a3bf886..f4e5760e2 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -133,6 +133,8 @@ content script はユーザーが閲覧中の任意のページ上で動作す - **`@property` は Shadow DOM 内で無視される**: Tailwind v4 が `@property` で登録する `--tw-*` の初期値は、 `src/lib/postcss-shadow-dom-properties.ts`(PostCSS プラグイン)が Shadow root に注入される CSS 内で宣言している。 `@property` をドキュメント(`document.head`)へ注入するなど、Shadow root の外へスタイルを出す方法は取らないこと。 +- **CSS Modules のルールはレイヤー外のため Tailwind のユーティリティ(`@layer utilities`)より常に優先される**: + 同じ要素に付けるユーティリティ(`hover:bg-accent` 等)と同じプロパティを CSS Modules 側で宣言しないこと(`background: none` 等のリセットも含む)。 - **CSS Modules で `@apply` を使う場合**は、ファイル先頭に `@reference "<相対パス>/global.css";` を書くこと。 **パスエイリアス**: diff --git a/packages/extension/src/components/menu/Menu.module.css b/packages/extension/src/components/menu/Menu.module.css index a446d230d..048bba1b4 100644 --- a/packages/extension/src/components/menu/Menu.module.css +++ b/packages/extension/src/components/menu/Menu.module.css @@ -7,8 +7,11 @@ .item { display: flex; align-items: center; - padding: var(--sc-padding) calc(var(--sc-padding) * 2) var(--sc-padding) calc(var(--sc-padding) * 1.5); - height: calc(max(var(--sc-font-size), var(--sc-image-size, 16px)) + var(--sc-padding) * 2); + padding: var(--sc-padding) calc(var(--sc-padding) * 2) var(--sc-padding) + calc(var(--sc-padding) * 1.5); + height: calc( + max(var(--sc-font-size), var(--sc-image-size, 16px)) + var(--sc-padding) * 2 + ); box-sizing: border-box; width: 100%; white-space: pre; @@ -26,8 +29,11 @@ } } +/* + * No background reset here: preflight already makes buttons transparent, and + * this unlayered rule would override layered utilities such as `hover:bg-accent`. + */ .button { - background: none; border: none; cursor: pointer; } diff --git a/packages/extension/src/lib/__tests__/postcss-shadow-dom-properties.test.ts b/packages/extension/src/lib/__tests__/postcss-shadow-dom-properties.test.ts index 2037a8310..b7a05cb0b 100644 --- a/packages/extension/src/lib/__tests__/postcss-shadow-dom-properties.test.ts +++ b/packages/extension/src/lib/__tests__/postcss-shadow-dom-properties.test.ts @@ -66,7 +66,7 @@ describe("postcss-shadow-dom-properties", () => { expect(declsOf(rules[1])).toEqual({ "--tw-blur": "0" }) }) - it("SDP-03: 正常系: initial-value を持たない @property は宣言しない", async () => { + it("SDP-03: 正常系: initial-value を持たない非継承の @property は全要素で initial にリセットする", async () => { // Arrange const css = ` @property --tw-ring-color { syntax: "*"; inherits: false; } @@ -77,8 +77,32 @@ describe("postcss-shadow-dom-properties", () => { const root = await run(css) // Assert + // Declared on every element so that a value set on an ancestor is not + // inherited by descendants. const rule = findLayer(root)!.first as postcss.Rule - expect(declsOf(rule)).toEqual({ "--tw-ring-shadow": "0 0 #0000" }) + expect(rule.selector).toBe(":host, *, ::before, ::after, ::backdrop") + expect(declsOf(rule)).toEqual({ + "--tw-ring-color": "initial", + "--tw-ring-shadow": "0 0 #0000", + }) + }) + + it("SDP-10: 正常系: initial-value を持たない継承の @property は :host / :root で initial にリセットする", async () => { + // Arrange + const css = ` + @property --tw-leading { syntax: "*"; inherits: true; } + ` + + // Act + const root = await run(css) + + // Assert + // Declared on the shadow host so that values from the host page do not + // leak into the shadow tree. + const rules = findLayer(root)!.nodes as postcss.Rule[] + expect(rules).toHaveLength(1) + expect(rules[0].selector).toBe(":host, :root") + expect(declsOf(rules[0])).toEqual({ "--tw-leading": "initial" }) }) it("SDP-04: 正常系: 接頭辞が一致しない @property は対象外とする", async () => { diff --git a/packages/extension/src/lib/postcss-shadow-dom-properties.ts b/packages/extension/src/lib/postcss-shadow-dom-properties.ts index 9a8560088..c75dae767 100644 --- a/packages/extension/src/lib/postcss-shadow-dom-properties.ts +++ b/packages/extension/src/lib/postcss-shadow-dom-properties.ts @@ -50,12 +50,13 @@ const shadowDomProperties: PluginCreator = (opts = {}) => { if (decl.prop === "initial-value") initialValue = decl.value if (decl.prop === "inherits") inherits = decl.value.trim() === "true" }) - // Without an initial value the property starts as the guaranteed-invalid - // value, which is the same as leaving it undeclared. - if (initialValue === undefined) return - + // Without an initial value the registered property starts as the + // guaranteed-invalid value. It still has to be declared: unregistered + // custom properties always inherit, so leaving it undeclared would let + // values from ancestors (or the host page) leak in. `initial` resets an + // unregistered custom property to the guaranteed-invalid value. const target = inherits ? inherited : nonInherited - target.set(name, initialValue) + target.set(name, initialValue ?? "initial") }) const hasProperties = nonInherited.size > 0 || inherited.size > 0 From 3323d00089501407cd6fdc8693d281542edfbcdd Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 15:15:09 +0900 Subject: [PATCH 33/84] fix(extension): center dialog enter animation on Tailwind CSS v4 Tailwind v4 outputs translate utilities as the `translate` property, which stacks with the slide keyframes' `transform`, so the dialog slid in from the top-left. Drop the slide classes as upstream shadcn does. Co-Authored-By: Claude Opus 5.5 --- packages/extension/src/components/ui/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/extension/src/components/ui/dialog.tsx b/packages/extension/src/components/ui/dialog.tsx index da9ce871a..7d3534a81 100644 --- a/packages/extension/src/components/ui/dialog.tsx +++ b/packages/extension/src/components/ui/dialog.tsx @@ -48,7 +48,7 @@ const DialogContent = React.forwardRef< Date: Tue, 20 Oct 2026 17:02:11 +0900 Subject: [PATCH 34/84] fix(extension): render menu hover area outside animated popover content The hover area was rendered inside PopoverContent, so the content's zoom/slide enter transforms were also applied to the part covering the anchor. The area moved during the animation, causing unintended mouseenter/mouseleave and the folder menu toggling repeatedly. - Render the area via a portal into the anchor's root (ShadowRoot or body) with fixed viewport coordinates, and follow page scroll/resize - Ignore animation/transition events bubbling from descendants - Skip re-render when neither position nor size has changed Co-Authored-By: Claude Opus 5.5 --- .../src/components/menu/HoverArea.tsx | 95 +++++++++++++++---- 1 file changed, 74 insertions(+), 21 deletions(-) diff --git a/packages/extension/src/components/menu/HoverArea.tsx b/packages/extension/src/components/menu/HoverArea.tsx index 527903cf0..7a8825bdb 100644 --- a/packages/extension/src/components/menu/HoverArea.tsx +++ b/packages/extension/src/components/menu/HoverArea.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, type RefObject } from "react" +import { createPortal } from "react-dom" type Props = { anchorRef: RefObject @@ -8,24 +9,67 @@ type Props = { type Placement = "top" | "bottom" | "left" | "right" +type Rects = { + anchor: DOMRect + content: DOMRect +} + +const isSameRect = (a: DOMRect, b: DOMRect) => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height + +/** + * Returns the portal container for the given element. + * Uses the ShadowRoot when the element lives in shadow DOM so that the + * hover area shares the same style scope, otherwise falls back to the body. + */ +const getPortalContainer = (elm: HTMLElement): Element | ShadowRoot => { + const root = elm.getRootNode() + return root instanceof ShadowRoot ? root : document.body +} + +/** + * Invisible area that bridges the anchor and the content so that the pointer + * can move between them without leaving the hover state. + * + * It is rendered through a portal outside the content element so that the + * content's enter/exit transforms (zoom, slide) are not applied to the area. + * React synthetic mouseenter/mouseleave events follow the component tree, + * so hovering this area is still treated as hovering the content. + */ export const HoverArea = (props: Props) => { const { anchorRef, contentRef, isHorizontal } = props - const [anchorRect, setAnchorRect] = useState(null) - const [contentRect, setContentRect] = useState(null) + const [rects, setRects] = useState(null) + const [container, setContainer] = useState(null) useEffect(() => { const anchor = anchorRef.current const content = contentRef.current if (!anchor || !content) return + setContainer(getPortalContainer(anchor)) + let rafId: number | null = null let isMonitoring = false let disposed = false + let last: Rects | null = null const update = () => { if (disposed) return - setAnchorRect(anchor.getBoundingClientRect()) - setContentRect(content.getBoundingClientRect()) + const next = { + anchor: anchor.getBoundingClientRect(), + content: content.getBoundingClientRect(), + } + + // Skip re-render when neither position nor size has changed + if ( + last && + isSameRect(last.anchor, next.anchor) && + isSameRect(last.content, next.content) + ) { + return + } + last = next + setRects(next) } const loop = () => { @@ -59,8 +103,15 @@ export const HoverArea = (props: Props) => { // Fallback: stop after 500ms if no animation events fire const fallbackTimeout = setTimeout(stop, 500) - const onAnimationStart = () => start() - const onAnimationEnd = () => { + // Animation and transition events bubble, so ignore those from descendants + // (e.g. hover transitions of menu items) to avoid stopping monitoring + // while the anchor or content itself is still animating. + const onAnimationStart = (e: Event) => { + if (e.target !== e.currentTarget) return + start() + } + const onAnimationEnd = (e: Event) => { + if (e.target !== e.currentTarget) return clearTimeout(fallbackTimeout) stop() } @@ -86,11 +137,18 @@ export const HoverArea = (props: Props) => { resizeObserver.observe(anchor) resizeObserver.observe(content) + // The area is fixed to the viewport, so follow the elements on page scroll + const scrollOptions = { capture: true, passive: true } as const + window.addEventListener("scroll", update, scrollOptions) + window.addEventListener("resize", update) + return () => { disposed = true stop() clearTimeout(fallbackTimeout) resizeObserver.disconnect() + window.removeEventListener("scroll", update, scrollOptions) + window.removeEventListener("resize", update) startEvents.forEach((e) => { anchor.removeEventListener(e, onAnimationStart) content.removeEventListener(e, onAnimationStart) @@ -102,14 +160,12 @@ export const HoverArea = (props: Props) => { } }, [anchorRef, contentRef]) - const anchor = anchorRect - const content = contentRect - - if (!anchor || !content) { + if (!rects || !container) { return null } + const { anchor, content } = rects - // SVG size and position + // SVG size and position (viewport coordinates) const x = Math.min(anchor.x, content.x) const y = Math.min(anchor.y, content.y) const width = Math.max(anchor.right, content.right) - x @@ -127,8 +183,6 @@ export const HoverArea = (props: Props) => { ? "left" : "right" - let top = 0 - let left = 0 let d switch (placement) { @@ -142,7 +196,6 @@ export const HoverArea = (props: Props) => { ${anchor.x} ${anchor.top} h ${anchor.width} z` - top = anchor.top - content.top break case "bottom": d = `M ${anchor.left} ${anchor.bottom} @@ -165,7 +218,6 @@ export const HoverArea = (props: Props) => { h ${-(anchor.width / 4)} v ${-anchor.height} z` - left = -anchor.width + 2 break case "right": d = `M ${anchor.right} ${anchor.top} @@ -177,20 +229,20 @@ export const HoverArea = (props: Props) => { h ${anchor.width / 4} v ${-anchor.height} z` - left = -2 break } - return ( + return createPortal( { fillOpacity="0" style={{ pointerEvents: "auto" }} /> - + , + container, ) } From 3d261091721071367b25a5103155393410d0a9ba Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 20 Oct 2026 17:23:15 +0900 Subject: [PATCH 35/84] fix(extension): unmount hover area while popover is closing PopoverContent stays mounted during its exit animation, so the hover area of a closing folder kept capturing the pointer. Its bridge overlaps the lower part of the neighboring folder and morphs with the exit animation, which toggled the previous and the newly opened folder back and forth and made the open folder's background blink. Render the hover area only while the popover is open. Co-Authored-By: Claude Opus 5.5 --- .../extension/src/components/menu/MenuFolder.tsx | 15 ++++++++++----- .../src/components/pageAction/PageActionItem.tsx | 14 +++++++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/components/menu/MenuFolder.tsx b/packages/extension/src/components/menu/MenuFolder.tsx index ec2f78679..091306242 100644 --- a/packages/extension/src/components/menu/MenuFolder.tsx +++ b/packages/extension/src/components/menu/MenuFolder.tsx @@ -194,11 +194,16 @@ export const MenuFolder = (props: { ))}
- + {/* Unmount while closing so the bridge of a folder being closed does + not capture the pointer over neighboring folders during the exit + animation, which would toggle the open folder back and forth. */} + {isOpen && ( + + )} ) diff --git a/packages/extension/src/components/pageAction/PageActionItem.tsx b/packages/extension/src/components/pageAction/PageActionItem.tsx index ddfdfb62d..bd46b50a4 100644 --- a/packages/extension/src/components/pageAction/PageActionItem.tsx +++ b/packages/extension/src/components/pageAction/PageActionItem.tsx @@ -231,11 +231,15 @@ export function PageActionItem(props: Props): JSX.Element {
- + {/* Unmount while closing so the bridge does not capture the + pointer during the exit animation. */} + {isOpen && ( + + )} )} From c904f3ca0988ed956cc07195aca5156742f579cf Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 21 Oct 2026 09:44:03 +0900 Subject: [PATCH 36/84] fix(extension): remove background transition from menu items On Chrome (macOS), the background transition on the folder button combined with the end of the popover enter/exit animation dropped the open folder's background for one frame, which looked like a flicker. - Remove the background transition from menu items and folders - Drop the hover background and the undefined itemOnlyIcon class from the folder button - Update @radix-ui/react-dialog, react-menubar and react-popover Co-Authored-By: Claude Opus 5.5 --- packages/extension/package.json | 7 +- .../src/components/menu/Menu.module.css | 9 +- .../src/components/menu/MenuFolder.tsx | 28 +- yarn.lock | 2018 +++++++++-------- 4 files changed, 1041 insertions(+), 1021 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index 48cb280b6..c2c11a490 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -26,10 +26,10 @@ "@dnd-kit/sortable": "^10.0.0", "@hookform/resolvers": "^4.0.0", "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dialog": "^1.3.1", "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-menubar": "^1.1.16", - "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-menubar": "^1.2.1", + "@radix-ui/react-popover": "^1.3.1", "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.2.9", @@ -44,6 +44,7 @@ "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cn": "^0.5.0", "colorthief": "^2.6.0", "embla-carousel-react": "^8.6.0", "get-xpath": "^3.3.0", diff --git a/packages/extension/src/components/menu/Menu.module.css b/packages/extension/src/components/menu/Menu.module.css index 048bba1b4..3c8f0fce5 100644 --- a/packages/extension/src/components/menu/Menu.module.css +++ b/packages/extension/src/components/menu/Menu.module.css @@ -7,11 +7,8 @@ .item { display: flex; align-items: center; - padding: var(--sc-padding) calc(var(--sc-padding) * 2) var(--sc-padding) - calc(var(--sc-padding) * 1.5); - height: calc( - max(var(--sc-font-size), var(--sc-image-size, 16px)) + var(--sc-padding) * 2 - ); + padding: var(--sc-padding) calc(var(--sc-padding) * 2) var(--sc-padding) calc(var(--sc-padding) * 1.5); + height: calc(max(var(--sc-font-size), var(--sc-image-size, 16px)) + var(--sc-padding) * 2); box-sizing: border-box; width: 100%; white-space: pre; @@ -20,7 +17,6 @@ text-wrap: nowrap; font-size: var(--sc-font-size); font-family: var(--font-family); - transition: ease-out 0.15s background; color: var(--sc-font-color); &:disabled { @@ -102,7 +98,6 @@ .folder { cursor: default; padding-right: calc(var(--sc-padding) / 2); - transition: ease-out 0.15s background; color: var(--sc-font-color); } diff --git a/packages/extension/src/components/menu/MenuFolder.tsx b/packages/extension/src/components/menu/MenuFolder.tsx index 091306242..959e3b243 100644 --- a/packages/extension/src/components/menu/MenuFolder.tsx +++ b/packages/extension/src/components/menu/MenuFolder.tsx @@ -109,19 +109,19 @@ export const MenuFolder = (props: { }, [isOpen]) const menubarStyle = isHorizontalContent ? { - maxWidth: - baseSize * 10 /* buttons */ + - 1 * 9 /* gap */ + - 2 * 2 /* padding */ + - 1 * 2 /* border */, - } + maxWidth: + baseSize * 10 /* buttons */ + + 1 * 9 /* gap */ + + 2 * 2 /* padding */ + + 1 * 2 /* border */, + } : { - maxHeight: - baseSize * 11.5 /* buttons */ + - 1 * 10 /* gap */ + - 2 * 2 /* padding */ + - 1 * 2 /* border */, - } + maxHeight: + baseSize * 11.5 /* buttons */ + + 1 * 10 /* gap */ + + 2 * 2 /* padding */ + + 1 * 2 /* border */, + } return ( @@ -129,12 +129,10 @@ export const MenuFolder = (props: { diff --git a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx index ce3ec31ca..5ca3c32c8 100644 --- a/packages/extension/src/components/onboarding/OnboardingTargetText.tsx +++ b/packages/extension/src/components/onboarding/OnboardingTargetText.tsx @@ -6,9 +6,13 @@ import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion" import { useSelectContext } from "@/hooks/useSelectContext" import { useSelectionDemo } from "./useSelectionDemo" import { OnboardingCallout } from "./OnboardingCallout" +import type { OnboardingBackground } from "./onboardingVariantUi" type Props = { text: string + // The page background behind the card; on the "glow" background the card + // is solid white so it stands out from the blue glow. + background: OnboardingBackground // True once the user has actually selected the text (any later phase), // rendering it as a static, already-selected highlight instead. selected: boolean @@ -24,7 +28,12 @@ type Props = { // other "do this next" hint in the onboarding flow (see OnboardingCallout) // so first-time users recognize this as something to select rather than // something to read. -export function OnboardingTargetText({ text, selected, calloutDelay }: Props) { +export function OnboardingTargetText({ + text, + background, + selected, + calloutDelay, +}: Props) { const prefersReducedMotion = usePrefersReducedMotion() const { selectionText } = useSelectContext() const textRef = useRef(null) @@ -45,7 +54,8 @@ export function OnboardingTargetText({ text, selected, calloutDelay }: Props) { ref={setCardElm} className={cn( "relative max-w-[560px] rounded-lg border px-[26px] py-5 text-left text-base leading-[1.9] text-slate-800 select-text", - "border-slate-200 bg-muted/80", + "border-slate-200", + background === "glow" ? "bg-white" : "bg-muted/80", !selected && !runDemo && "[--onboarding-ring-color:rgba(8,47,73,0.16)] animate-onboarding-ring motion-reduce:animate-none", diff --git a/packages/extension/src/components/onboarding/OnboardingWelcome.tsx b/packages/extension/src/components/onboarding/OnboardingWelcome.tsx index 07dd744a6..4cddba01f 100644 --- a/packages/extension/src/components/onboarding/OnboardingWelcome.tsx +++ b/packages/extension/src/components/onboarding/OnboardingWelcome.tsx @@ -63,7 +63,7 @@ export function OnboardingWelcome({ onDone }: Props) { aria-label={t("onboarding_welcomeContinue")} data-testid="onboarding-welcome" className={clsx( - "fixed inset-0 z-30 flex cursor-default flex-col items-center justify-center gap-10 overflow-hidden bg-white", + "fixed inset-0 z-30 flex cursor-default flex-col items-center justify-center gap-12 overflow-hidden bg-white", exiting && "animate-onboarding-blur-out motion-reduce:animate-none", )} > @@ -72,16 +72,17 @@ export function OnboardingWelcome({ onDone }: Props) { - - + + - +

{t("onboarding_welcomeMessage")}

- +

{t("onboarding_welcomeIntroduction")}

diff --git a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx index c2309f131..5786340c4 100644 --- a/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx +++ b/packages/extension/src/components/onboarding/steps/StepAiPromptCommand.tsx @@ -206,9 +206,14 @@ export function StepAiPromptCommand({ onboarding }: Props) { {!isValueShown && ( <> - + @@ -230,7 +235,10 @@ export function StepAiPromptCommand({ onboarding }: Props) {