diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 683c16e..d62290d 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,6 +1,6 @@ import { defineBackground } from 'wxt/utils/define-background'; import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types'; -import { ACTIONS, ACTION_LABELS, CONTEXT_MENU_STYLES } from '@lib/actions'; +import { CONTEXT_MENU_ENTRIES, findContextMenuEntry } from '@lib/actions'; import { decryptApiKey } from '@lib/crypto'; import { callProvider, getSystemPrompt, listModels } from '@lib/providers'; @@ -40,35 +40,26 @@ export default defineBackground(() => { // ─── Context menus ─────────────────────────────────────────────────────── chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.removeAll(() => { - ACTIONS.forEach(action => { + CONTEXT_MENU_ENTRIES.forEach((entry) => { chrome.contextMenus.create({ - id: `lexai-${action}`, - title: `⚡ LexAI: ${ACTION_LABELS[action]}`, + id: entry.id, + parentId: entry.parentId, + title: entry.title, contexts: ['selection'], }); - CONTEXT_MENU_STYLES.forEach(style => { - chrome.contextMenus.create({ - id: `lexai-${action}-${style.toLowerCase()}`, - parentId: `lexai-${action}`, - title: style, - contexts: ['selection'], - }); - }); }); }); }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (!info.selectionText || !tab?.id) return; - // Parse action and style from menuItemId e.g. "lexai-fix-formal" - const parts = info.menuItemId.toString().replace('lexai-', '').split('-'); - const action = parts[0]; - const style = parts[1] ? parts[1].charAt(0).toUpperCase() + parts[1].slice(1) : 'Default'; + const entry = findContextMenuEntry(info.menuItemId.toString()); + if (!entry) return; chrome.tabs.sendMessage(tab.id, { type: 'lexai-context-menu', - action, + action: entry.action, text: info.selectionText, - style, + style: entry.style, }); }); diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 1f28cee..7a39f3f 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -1,6 +1,6 @@ import { defineContentScript } from 'wxt/utils/define-content-script'; import { isExtensionValid, safeSendMessage } from '@lib/messaging'; -import { WRITING_STYLES } from '@lib/actions'; +import { MIN_SELECTION_LENGTH, WRITING_STYLES } from '@lib/actions'; export default defineContentScript({ matches: [''], @@ -48,7 +48,7 @@ export default defineContentScript({ const end = el.selectionEnd ?? -1; if (end - start >= 2) { const text = el.value.substring(start, end).trim(); - if (text.length > 10) { + if (text.length >= MIN_SELECTION_LENGTH) { selectedText = text; storedStart = start; storedEnd = end; @@ -64,7 +64,7 @@ export default defineContentScript({ const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const text = sel.toString().trim(); - if (text.length > 10) { + if (text.length >= MIN_SELECTION_LENGTH) { selectedText = text; storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later storedElement = null; diff --git a/src/lib/actions.ts b/src/lib/actions.ts index ffc8af0..e80ebb1 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -21,3 +21,43 @@ export type WritingStyle = (typeof WRITING_STYLES)[number]; // Context menus omit 'Default' — the parent action item already covers it. export const CONTEXT_MENU_STYLES = WRITING_STYLES.filter((s) => s !== 'Default'); + +// Minimum selection length (chars, after trim) before the floating toolbar +// appears. Kept deliberately above 1-2 chars so accidental double-click +// selections don't trigger the UI. +export const MIN_SELECTION_LENGTH = 10; + +// ─── Context-menu registry ──────────────────────────────────────────────────── +// Declarative id -> {action, style} mapping. Replaces the old split('-') +// parsing of menuItemId, which only worked because no action or style name +// contained a hyphen. Order matters: each parent precedes its children so +// chrome.contextMenus.create never sees an unknown parentId. + +export interface ContextMenuEntry { + id: string; + action: ActionId; + style: WritingStyle; + // 'Default' entries are top-level parents; styled entries nest under them. + parentId?: string; + title: string; +} + +export const CONTEXT_MENU_ENTRIES: ContextMenuEntry[] = ACTIONS.flatMap((action) => [ + { + id: `lexai-${action}`, + action, + style: 'Default' as WritingStyle, + title: `⚡ LexAI: ${ACTION_LABELS[action]}`, + }, + ...CONTEXT_MENU_STYLES.map((style) => ({ + id: `lexai-${action}-${style.toLowerCase()}`, + action, + style, + parentId: `lexai-${action}`, + title: style as string, + })), +]); + +export function findContextMenuEntry(menuItemId: string): ContextMenuEntry | undefined { + return CONTEXT_MENU_ENTRIES.find((e) => e.id === menuItemId); +} diff --git a/tests/unit/actions.test.ts b/tests/unit/actions.test.ts new file mode 100644 index 0000000..4ab16d4 --- /dev/null +++ b/tests/unit/actions.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { + ACTIONS, + ACTION_LABELS, + WRITING_STYLES, + CONTEXT_MENU_STYLES, + CONTEXT_MENU_ENTRIES, + findContextMenuEntry, +} from '@lib/actions'; + +describe('context-menu registry', () => { + it('contains 5 parents + 5x5 style children = 30 entries (parity with old loops)', () => { + expect(CONTEXT_MENU_ENTRIES).toHaveLength(30); + expect(CONTEXT_MENU_ENTRIES.filter((e) => !e.parentId)).toHaveLength(ACTIONS.length); + }); + + it('every parent precedes its children (contextMenus.create ordering)', () => { + CONTEXT_MENU_ENTRIES.forEach((entry, i) => { + if (entry.parentId) { + const parentIndex = CONTEXT_MENU_ENTRIES.findIndex((e) => e.id === entry.parentId); + expect(parentIndex).toBeGreaterThanOrEqual(0); + expect(parentIndex).toBeLessThan(i); + } + }); + }); + + it('maps every previous menu id to the same action/style as the old split("-") parser', () => { + // The old parser: strip 'lexai-', split on '-', capitalize the style part. + for (const action of ACTIONS) { + const parent = findContextMenuEntry(`lexai-${action}`); + expect(parent).toMatchObject({ action, style: 'Default' }); + expect(parent!.title).toBe(`⚡ LexAI: ${ACTION_LABELS[action]}`); + + for (const style of CONTEXT_MENU_STYLES) { + const child = findContextMenuEntry(`lexai-${action}-${style.toLowerCase()}`); + expect(child).toMatchObject({ action, style, parentId: `lexai-${action}`, title: style }); + } + } + }); + + it('returns undefined for foreign menu ids', () => { + expect(findContextMenuEntry('some-other-extension-item')).toBeUndefined(); + }); + + it('style lists stay consistent', () => { + expect(CONTEXT_MENU_STYLES).toEqual(WRITING_STYLES.filter((s) => s !== 'Default')); + }); +});