refactor: declarative context-menu registry; named selection threshold
CONTEXT_MENU_ENTRIES maps every menu id to {action, style, parentId,
title}, replacing the split('-') id parsing that only worked because no
name contained a hyphen. Foreign menu ids are now ignored explicitly.
Parity pinned by tests enumerating all 30 previous ids. The 10-char
toolbar trigger threshold is now the named MIN_SELECTION_LENGTH.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { defineBackground } from 'wxt/utils/define-background';
|
import { defineBackground } from 'wxt/utils/define-background';
|
||||||
import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types';
|
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 { decryptApiKey } from '@lib/crypto';
|
||||||
import { callProvider, getSystemPrompt, listModels } from '@lib/providers';
|
import { callProvider, getSystemPrompt, listModels } from '@lib/providers';
|
||||||
|
|
||||||
@@ -40,35 +40,26 @@ export default defineBackground(() => {
|
|||||||
// ─── Context menus ───────────────────────────────────────────────────────
|
// ─── Context menus ───────────────────────────────────────────────────────
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
chrome.contextMenus.removeAll(() => {
|
chrome.contextMenus.removeAll(() => {
|
||||||
ACTIONS.forEach(action => {
|
CONTEXT_MENU_ENTRIES.forEach((entry) => {
|
||||||
chrome.contextMenus.create({
|
chrome.contextMenus.create({
|
||||||
id: `lexai-${action}`,
|
id: entry.id,
|
||||||
title: `⚡ LexAI: ${ACTION_LABELS[action]}`,
|
parentId: entry.parentId,
|
||||||
|
title: entry.title,
|
||||||
contexts: ['selection'],
|
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) => {
|
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||||
if (!info.selectionText || !tab?.id) return;
|
if (!info.selectionText || !tab?.id) return;
|
||||||
// Parse action and style from menuItemId e.g. "lexai-fix-formal"
|
const entry = findContextMenuEntry(info.menuItemId.toString());
|
||||||
const parts = info.menuItemId.toString().replace('lexai-', '').split('-');
|
if (!entry) return;
|
||||||
const action = parts[0];
|
|
||||||
const style = parts[1] ? parts[1].charAt(0).toUpperCase() + parts[1].slice(1) : 'Default';
|
|
||||||
chrome.tabs.sendMessage(tab.id, {
|
chrome.tabs.sendMessage(tab.id, {
|
||||||
type: 'lexai-context-menu',
|
type: 'lexai-context-menu',
|
||||||
action,
|
action: entry.action,
|
||||||
text: info.selectionText,
|
text: info.selectionText,
|
||||||
style,
|
style: entry.style,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineContentScript } from 'wxt/utils/define-content-script';
|
import { defineContentScript } from 'wxt/utils/define-content-script';
|
||||||
import { isExtensionValid, safeSendMessage } from '@lib/messaging';
|
import { isExtensionValid, safeSendMessage } from '@lib/messaging';
|
||||||
import { WRITING_STYLES } from '@lib/actions';
|
import { MIN_SELECTION_LENGTH, WRITING_STYLES } from '@lib/actions';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
@@ -48,7 +48,7 @@ export default defineContentScript({
|
|||||||
const end = el.selectionEnd ?? -1;
|
const end = el.selectionEnd ?? -1;
|
||||||
if (end - start >= 2) {
|
if (end - start >= 2) {
|
||||||
const text = el.value.substring(start, end).trim();
|
const text = el.value.substring(start, end).trim();
|
||||||
if (text.length > 10) {
|
if (text.length >= MIN_SELECTION_LENGTH) {
|
||||||
selectedText = text;
|
selectedText = text;
|
||||||
storedStart = start;
|
storedStart = start;
|
||||||
storedEnd = end;
|
storedEnd = end;
|
||||||
@@ -64,7 +64,7 @@ export default defineContentScript({
|
|||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel && sel.rangeCount > 0) {
|
if (sel && sel.rangeCount > 0) {
|
||||||
const text = sel.toString().trim();
|
const text = sel.toString().trim();
|
||||||
if (text.length > 10) {
|
if (text.length >= MIN_SELECTION_LENGTH) {
|
||||||
selectedText = text;
|
selectedText = text;
|
||||||
storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later
|
storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later
|
||||||
storedElement = null;
|
storedElement = null;
|
||||||
|
|||||||
@@ -21,3 +21,43 @@ export type WritingStyle = (typeof WRITING_STYLES)[number];
|
|||||||
|
|
||||||
// Context menus omit 'Default' — the parent action item already covers it.
|
// Context menus omit 'Default' — the parent action item already covers it.
|
||||||
export const CONTEXT_MENU_STYLES = WRITING_STYLES.filter((s) => s !== 'Default');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
48
tests/unit/actions.test.ts
Normal file
48
tests/unit/actions.test.ts
Normal file
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user