Files
LexAI/packages/chrome/entrypoints/content.ts
john kevin asprec d63d698b57 feat: add popup and options pages for LexAI extension
- Created index.html for options page with basic structure.
- Implemented Popup component in Popup.tsx with state management for user input and actions.
- Added index.html for popup page with necessary scripts.
- Included various icon assets for the extension.
- Designed SVG icon for the extension with gradient background and lightning bolt.
- Added multiple screenshots for Chrome Web Store listing.
- Configured WXT for building the Chrome extension with manifest settings.
2026-08-13 19:16:54 +08:00

1406 lines
54 KiB
TypeScript

import { defineContentScript } from 'wxt/utils/define-content-script';
import { isExtensionValid, safeSendMessage } from '@lib/messaging';
import { MIN_SELECTION_LENGTH, WRITING_STYLES, PROMPT_PATTERNS, PROMPT_PERSONAS, PROMPT_FORMATS, resolvePromptPattern } from '@lib/actions';
export default defineContentScript({
matches: ['<all_urls>'],
main() {
// Debug logging is stripped in production builds — selection text must never
// reach the host page's console outside dev.
const isDev = import.meta.env.COMMAND === 'serve';
const debugLog = (...args: unknown[]) => {
if (isDev) console.log(...args);
};
const debugWarn = (...args: unknown[]) => {
if (isDev) console.warn(...args);
};
debugLog('LexAI content script loaded');
// ─── State ───────────────────────────────────────────────────────────────
let toolbar: HTMLElement | null = null;
let modal: HTMLElement | null = null;
// Removes the document-level listeners registered for the current toolbar's
// "More" menu — without this every selection leaked two document listeners.
let toolbarListenerCleanup: (() => void) | null = null;
// Stored immediately on mouseup — used during Replace (selection is lost by then)
let selectedText = '';
let storedStart = -1;
let storedEnd = -1;
let storedElement: HTMLTextAreaElement | HTMLInputElement | null = null;
let storedRange: Range | null = null;
// Lock flag — prevents mouseup from resetting stored selection while modal is open
let selectionLocked = false;
// Writing style — persisted in chrome.storage.local
let currentStyle = 'Default';
chrome.storage.local.get(['writingStyle'], (res) => {
currentStyle = (res.writingStyle as string) || 'Default';
});
// ─── Selection capture (called immediately on mouseup) ────────────────────
function captureSelectionNow(): boolean {
if (selectionLocked) return true; // selection is locked — don't reset
const el = document.activeElement;
// 1) textarea / input — grab positions while they're still valid
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
const start = el.selectionStart ?? -1;
const end = el.selectionEnd ?? -1;
if (end - start >= 2) {
const text = el.value.substring(start, end).trim();
if (text.length >= MIN_SELECTION_LENGTH) {
selectedText = text;
storedStart = start;
storedEnd = end;
storedElement = el;
storedRange = null;
return true;
}
}
return false;
}
// 2) contenteditable / regular DOM
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const text = sel.toString().trim();
if (text.length >= MIN_SELECTION_LENGTH) {
selectedText = text;
storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later
storedElement = null;
storedStart = -1;
storedEnd = -1;
return true;
}
}
return false;
}
// ─── Capture selection on toolbar button mousedown ────────────────────────
// Called from each toolbar button's mousedown — at this point focus has NOT
// shifted yet (even without e.preventDefault), so selection is guaranteed valid.
function captureForButton() {
if (selectionLocked) return;
const activeEl = document.activeElement;
if (activeEl instanceof HTMLTextAreaElement || activeEl instanceof HTMLInputElement) {
const start = activeEl.selectionStart ?? -1;
const end = activeEl.selectionEnd ?? -1;
if (start >= 0 && end > start) {
storedStart = start;
storedEnd = end;
storedElement = activeEl;
storedRange = null;
selectedText = activeEl.value.substring(start, end);
debugLog('[LexAI captureForButton] textarea captured', { start, end, text: selectedText.substring(0, 40) });
}
} else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && sel.toString().trim().length > 0) {
storedRange = sel.getRangeAt(0).cloneRange();
storedElement = null;
storedStart = -1;
storedEnd = -1;
selectedText = sel.toString().trim();
debugLog('[LexAI captureForButton] DOM range captured', { text: selectedText.substring(0, 40) });
}
}
}
// ─── Replace using stored state ───────────────────────────────────────────
function replaceText(newText: string) {
// textarea / input path
if (storedElement) {
const el = storedElement;
const before = el.value.substring(0, storedStart);
const after = el.value.substring(storedEnd);
el.value = before + newText + after;
el.setSelectionRange(storedStart, storedStart + newText.length);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.focus();
return;
}
// DOM range path (contenteditable etc.)
if (storedRange) {
try {
storedRange.deleteContents();
const textNode = document.createTextNode(newText);
storedRange.insertNode(textNode);
// move caret after inserted text
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
const newRange = document.createRange();
newRange.setStartAfter(textNode);
newRange.collapse(true);
sel.addRange(newRange);
}
} catch (_) {
document.execCommand('insertText', false, newText);
} finally {
storedRange = null;
}
}
}
// ─── Toolbar ──────────────────────────────────────────────────────────────
function getToolbarPosition(rect: DOMRect) {
const TOOLBAR_W = 280;
const TOOLBAR_H = 40;
const MARGIN = 8;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Center above the selection rect
let left = rect.left + scrollX + rect.width / 2 - TOOLBAR_W / 2;
let top = rect.top + scrollY - TOOLBAR_H - MARGIN;
// Clamp horizontally to viewport
left = Math.max(scrollX + MARGIN, Math.min(left, scrollX + window.innerWidth - TOOLBAR_W - MARGIN));
// Flip below if not enough space above
if (top < scrollY + MARGIN) {
top = rect.bottom + scrollY + MARGIN;
}
return { top, left };
}
// Inject shared keyframe CSS once
function ensureLexAIStyles() {
if (document.getElementById('lexai-styles')) return;
const style = document.createElement('style');
style.id = 'lexai-styles';
style.textContent = `
@keyframes lexai-spin { to { transform: rotate(360deg); } }
@keyframes lexai-fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
`;
document.head.appendChild(style);
}
function showToolbar(rect: DOMRect) {
ensureLexAIStyles();
hideToolbar();
const { top, left } = getToolbarPosition(rect);
toolbar = document.createElement('div');
toolbar.id = 'lexai-toolbar';
toolbar.setAttribute('data-lexai', 'true');
Object.assign(toolbar.style, {
position: 'absolute',
top: `${top}px`,
left: `${left}px`,
zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius:'10px',
padding: '6px 8px',
display: 'flex',
gap: '4px',
boxShadow: '0 4px 24px rgba(0,0,0,0.45), 0 1px 3px rgba(0,0,0,0.3)',
border: '1px solid rgba(205,214,244,0.12)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
alignItems: 'center',
});
const actions: { label: string; action: string; color: string }[] = [
{ label: '✓ Fix', action: 'grammar', color: '#a6e3a1' },
{ label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' },
{ label: '↓ Shorten', action: 'shorten', color: '#fab387' },
{ label: '↑ Expand', action: 'expand', color: '#cba6f7' },
{ label: '💡 Explain', action: 'explain', color: '#2dd4bf' },
{ label: '🪄 Prompt', action: 'prompt', color: '#f9e2af' },
];
actions.forEach(({ label, action, color }) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.setAttribute('data-lexai', 'true');
Object.assign(btn.style, {
background: 'rgba(49,50,68,0.8)',
color: color,
border: '1px solid rgba(205,214,244,0.1)',
borderRadius: '7px',
padding: '4px 10px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.15s ease',
letterSpacing: '0.01em',
whiteSpace: 'nowrap',
});
btn.addEventListener('mouseenter', () => {
btn.style.background = 'rgba(69,71,90,0.9)';
btn.style.borderColor = color + '50';
btn.style.transform = 'translateY(-1px)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'rgba(49,50,68,0.8)';
btn.style.borderColor = 'rgba(205,214,244,0.1)';
btn.style.transform = 'translateY(0)';
});
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); // prevent focus shift away from textarea
captureForButton(); // re-capture selection while it's guaranteed valid
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Prompt opens the Prompt Builder dialog first — parameters, then run.
if (action === 'prompt') {
showPromptBuilderDialog();
} else {
runAction(action);
}
});
toolbar!.appendChild(btn);
});
// ─── More button (Copy As + Download) ────────────────────────────────────
const moreBtn = document.createElement('button');
moreBtn.textContent = '⋯ More';
moreBtn.setAttribute('data-lexai', 'true');
Object.assign(moreBtn.style, {
background: 'rgba(49,50,68,0.8)',
color: '#a6adc8',
border: '1px solid rgba(205,214,244,0.15)',
borderRadius: '6px',
padding: '5px 10px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
});
// Dropdown menu
let moreMenu: HTMLElement | null = null;
function closeMoveMenu() {
if (moreMenu) { moreMenu.remove(); moreMenu = null; }
}
moreBtn.addEventListener('mousedown', (e) => e.preventDefault());
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (moreMenu) { closeMoveMenu(); return; }
moreMenu = document.createElement('div');
moreMenu.setAttribute('data-lexai', 'true');
Object.assign(moreMenu.style, {
position: 'fixed',
background: 'rgba(30,30,46,0.98)',
border: '1px solid rgba(205,214,244,0.15)',
borderRadius: '8px',
padding: '4px',
zIndex: '2147483647',
minWidth: '140px',
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
});
// Position above the More button
const rect = moreBtn.getBoundingClientRect();
moreMenu.style.left = rect.left + 'px';
moreMenu.style.top = (rect.top - 8) + 'px';
moreMenu.style.transform = 'translateY(-100%)';
const menuItems = [
{ label: '⎘ Copy As', action: 'copy-as' },
{ label: '⬇ Download', action: 'download' },
];
menuItems.forEach(({ label, action }) => {
const item = document.createElement('button');
item.textContent = label;
item.setAttribute('data-lexai', 'true');
Object.assign(item.style, {
background: 'transparent',
color: '#cdd6f4',
border: 'none',
borderRadius: '6px',
padding: '7px 12px',
fontSize: '13px',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
});
item.addEventListener('mouseenter', () => { item.style.background = 'rgba(137,180,250,0.1)'; });
item.addEventListener('mouseleave', () => { item.style.background = 'transparent'; });
item.addEventListener('mousedown', (e) => e.preventDefault());
item.addEventListener('click', () => {
closeMoveMenu();
captureForButton(); // ensure selection is captured
if (action === 'download') {
downloadAsText(selectedText);
} else {
showCopyAsModal(selectedText);
}
});
moreMenu!.appendChild(item);
});
document.body.appendChild(moreMenu);
});
toolbar!.appendChild(moreBtn);
// Close dropdown on outside click / scroll; hideToolbar unregisters these
// (and closes any open menu) so listeners never accumulate across selections.
document.addEventListener('click', closeMoveMenu);
document.addEventListener('scroll', closeMoveMenu, { passive: true });
toolbarListenerCleanup = () => {
closeMoveMenu();
document.removeEventListener('click', closeMoveMenu);
document.removeEventListener('scroll', closeMoveMenu);
};
document.body.appendChild(toolbar);
// Re-clamp with the real width — the pre-append estimate undershoots now
// that the toolbar has six action buttons, which could push the last
// ones (🪄 Prompt, ⋯ More) past the right edge of the viewport.
const actualW = toolbar.offsetWidth;
let clampedLeft = rect.left + window.scrollX + rect.width / 2 - actualW / 2;
clampedLeft = Math.max(
window.scrollX + 8,
Math.min(clampedLeft, window.scrollX + window.innerWidth - actualW - 8),
);
toolbar.style.left = `${clampedLeft}px`;
}
function hideToolbar() {
toolbarListenerCleanup?.();
toolbarListenerCleanup = null;
if (toolbar) {
toolbar.remove();
toolbar = null;
}
}
// Close the Prompt Builder dialog (also used as the loading indicator for
// prompt requests — see showPromptBuilderDialog's Make Prompt handler).
function closePromptBuilder() {
document.getElementById('lexai-promptbuilder-overlay')?.remove();
}
// Extension context guard: isExtensionValid is imported from ~/lib/messaging.
function showErrorToast(msg: string) {
const toast = document.createElement('div');
toast.setAttribute('data-lexai', 'true');
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 999999;
background: #f38ba8; color: #1e1e2e; padding: 10px 16px;
border-radius: 8px; font-family: system-ui, sans-serif;
font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
function showToast(msg: string, isError = false) {
const toast = document.createElement('div');
toast.setAttribute('data-lexai', 'true');
toast.style.cssText = `
position: fixed; bottom: 20px; right: 20px; z-index: 999999;
background: ${isError ? '#f38ba8' : '#a6e3a1'}; color: #1e1e2e;
padding: 10px 16px; border-radius: 8px;
font-family: system-ui, sans-serif; font-size: 13px; font-weight: 600;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: lexai-fadein 0.15s ease;
`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2500);
}
// ─── LEXAI-20: Download as .txt ───────────────────────────────────────────
function downloadAsText(text: string) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `lexai-note-${timestamp}.txt`;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Downloaded: ${filename}`);
}
// ─── LEXAI-19: Copy As modal ──────────────────────────────────────────────
function showCopyAsModal(text: string) {
// Remove any existing copy-as modal
document.getElementById('lexai-copyAs-overlay')?.remove();
const formats = ['JSON', 'Markdown', 'Bullet List', 'Numbered List', 'CSV', 'HTML'];
const overlay = document.createElement('div');
overlay.id = 'lexai-copyAs-overlay';
overlay.setAttribute('data-lexai', 'true');
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
zIndex: '2147483646',
background: 'rgba(0,0,0,0.25)',
});
const picker = document.createElement('div');
picker.setAttribute('data-lexai', 'true');
Object.assign(picker.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius:'12px',
padding: '16px',
width: '280px',
boxShadow: '0 12px 40px rgba(0,0,0,0.55)',
border: '1px solid rgba(205,214,244,0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
});
// Header row
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
});
const title = document.createElement('div');
title.innerHTML = '⚡ <strong style="color:#cdd6f4">Copy As</strong>';
Object.assign(title.style, { fontSize: '12px', color: '#89b4fa' });
const closeX = document.createElement('button');
closeX.textContent = '✕';
Object.assign(closeX.style, {
background: 'none', border: 'none', color: '#6c7086',
cursor: 'pointer', fontSize: '14px', padding: '0 2px', lineHeight: '1',
});
closeX.addEventListener('click', () => overlay.remove());
header.appendChild(title);
header.appendChild(closeX);
picker.appendChild(header);
// Spinner slot (shown while loading)
const spinnerSlot = document.createElement('div');
spinnerSlot.setAttribute('data-lexai', 'true');
Object.assign(spinnerSlot.style, {
display: 'none',
justifyContent: 'center',
alignItems: 'center',
padding: '12px 0',
color: '#a6adc8',
fontSize: '13px',
gap: '8px',
});
spinnerSlot.innerHTML = '<span style="font-size:18px;animation:lexai-spin 0.8s linear infinite;display:inline-block">⟳</span> Formatting…';
picker.appendChild(spinnerSlot);
// 2-column grid of format buttons
const grid = document.createElement('div');
Object.assign(grid.style, {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px',
});
formats.forEach((fmt) => {
const btn = document.createElement('button');
btn.textContent = fmt;
btn.setAttribute('data-lexai', 'true');
Object.assign(btn.style, {
background: 'rgba(49,50,68,0.8)',
color: '#cdd6f4',
border: '1px solid rgba(205,214,244,0.12)',
borderRadius: '8px',
padding: '8px 10px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.12s ease',
textAlign: 'center',
});
btn.addEventListener('mouseenter', () => {
btn.style.background = 'rgba(69,71,90,0.9)';
btn.style.borderColor = '#89b4fa50';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'rgba(49,50,68,0.8)';
btn.style.borderColor = 'rgba(205,214,244,0.12)';
});
btn.addEventListener('click', async () => {
// Show spinner, hide grid
grid.style.display = 'none';
spinnerSlot.style.display = 'flex';
const response = await sendToBackground({
type: 'COPY_AS',
text,
format: fmt,
}) as { error?: string; result?: string } | null;
overlay.remove();
if (!response || response.error) {
showToast(`${response?.error ?? 'Unknown error'}`, true);
return;
}
try {
await navigator.clipboard.writeText(response.result ?? '');
showToast(`✓ Copied as ${fmt}!`);
} catch {
showToast('❌ Clipboard write failed', true);
}
});
grid.appendChild(btn);
});
picker.appendChild(grid);
overlay.appendChild(picker);
// Close on overlay click (outside picker)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
document.body.appendChild(overlay);
}
// ─── Prompt Builder dialog ────────────────────────────────────────────────
// Same parameters as the popup's Prompt Builder tab. Selections are saved
// to chrome.storage.local and the request is sent WITHOUT explicit params —
// the background applies the saved ones, so popup and page stay in sync.
function showPromptBuilderDialog() {
document.getElementById('lexai-promptbuilder-overlay')?.remove();
ensureLexAIStyles();
hideToolbar();
selectionLocked = true; // dialog clicks must not reset the stored selection
const overlay = document.createElement('div');
overlay.id = 'lexai-promptbuilder-overlay';
overlay.setAttribute('data-lexai', 'true');
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
zIndex: '2147483646',
background: 'rgba(0,0,0,0.25)',
});
const panel = document.createElement('div');
panel.setAttribute('data-lexai', 'true');
Object.assign(panel.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius:'12px',
padding: '16px',
width: '320px',
boxShadow: '0 12px 40px rgba(0,0,0,0.55)',
border: '1px solid rgba(205,214,244,0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
});
const close = () => {
selectionLocked = false;
overlay.remove();
};
// Header
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px',
});
const title = document.createElement('div');
title.innerHTML = '🪄 <strong style="color:#cdd6f4">Prompt Builder</strong>';
Object.assign(title.style, { fontSize: '13px', color: '#89b4fa' });
const closeX = document.createElement('button');
closeX.textContent = '✕';
closeX.setAttribute('data-lexai', 'true');
Object.assign(closeX.style, {
background: 'none', border: 'none', color: '#6c7086',
cursor: 'pointer', fontSize: '14px', padding: '0 2px', lineHeight: '1',
});
closeX.addEventListener('click', close);
header.appendChild(title);
header.appendChild(closeX);
panel.appendChild(header);
const selectStyle = {
flex: '1', background: 'rgba(49,50,68,0.95)', border: '1px solid rgba(205,214,244,0.2)',
borderRadius: '6px', color: '#cdd6f4', fontSize: '12px', padding: '5px 8px',
cursor: 'pointer', outline: 'none', minWidth: '0',
};
function makeRow(labelText: string, control: HTMLElement) {
const row = document.createElement('div');
Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' });
const label = document.createElement('span');
label.textContent = labelText;
Object.assign(label.style, { fontSize: '12px', color: '#a6adc8', width: '64px', flexShrink: '0' });
row.appendChild(label);
row.appendChild(control);
panel.appendChild(row);
return row;
}
// Plain flat-list select (Persona/Format), or a grouped select (Pattern)
// when `grouped` is true — 'auto' renders first as a bare option, then
// one <optgroup> per PROMPT_PATTERNS group.
function makeSelect(options: readonly string[]): HTMLSelectElement;
function makeSelect(options: typeof PROMPT_PATTERNS, grouped: true): HTMLSelectElement;
function makeSelect(options: readonly string[] | typeof PROMPT_PATTERNS, grouped?: true): HTMLSelectElement {
const sel = document.createElement('select');
sel.setAttribute('data-lexai', 'true');
Object.assign(sel.style, selectStyle);
if (grouped) {
const defs = options as typeof PROMPT_PATTERNS;
const addOption = (parent: Element, p: (typeof PROMPT_PATTERNS)[number]) => {
const opt = document.createElement('option');
opt.setAttribute('data-lexai', 'true');
opt.value = p.id;
opt.textContent = p.label;
opt.title = p.hint;
parent.appendChild(opt);
};
const auto = defs.find((p) => p.id === 'auto');
if (auto) addOption(sel, auto);
(['Direct', 'Reasoning', 'Agentic'] as const).forEach((group) => {
const inGroup = defs.filter((p) => p.id !== 'auto' && p.group === group);
if (inGroup.length === 0) return;
const optgroup = document.createElement('optgroup');
optgroup.setAttribute('data-lexai', 'true');
optgroup.label = group;
inGroup.forEach((p) => addOption(optgroup, p));
sel.appendChild(optgroup);
});
return sel;
}
(options as readonly string[]).forEach((o) => {
const opt = document.createElement('option');
opt.setAttribute('data-lexai', 'true');
opt.value = o;
opt.textContent = o;
sel.appendChild(opt);
});
return sel;
}
const selPattern = makeSelect(PROMPT_PATTERNS, true);
makeRow('Pattern:', selPattern);
const patternHint = document.createElement('div');
patternHint.setAttribute('data-lexai', 'true');
Object.assign(patternHint.style, {
fontSize: '11px', color: '#6c7086', margin: '2px 0 8px 72px',
minHeight: '28px', lineHeight: '1.3',
});
panel.appendChild(patternHint);
const updatePatternHint = () => {
patternHint.textContent = PROMPT_PATTERNS.find((p) => p.id === selPattern.value)?.hint ?? '';
};
selPattern.addEventListener('change', updatePatternHint);
updatePatternHint();
const selPersona = makeSelect(PROMPT_PERSONAS);
makeRow('Persona:', selPersona);
const customInput = document.createElement('input');
customInput.type = 'text';
customInput.placeholder = 'e.g. senior UX researcher';
customInput.setAttribute('data-lexai', 'true');
Object.assign(customInput.style, { ...selectStyle, cursor: 'text' });
const customRow = makeRow('', customInput);
customRow.style.display = 'none';
selPersona.addEventListener('change', () => {
customRow.style.display = selPersona.value === 'Custom…' ? 'flex' : 'none';
});
const selFormat = makeSelect(PROMPT_FORMATS);
makeRow('Format:', selFormat);
// Model — 'Default' = the configured model; live list loads in the background.
const selModel = makeSelect([]);
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = 'Default';
selModel.appendChild(defaultOpt);
makeRow('Model:', selModel);
// Prefill from saved settings, then fetch the model list.
chrome.storage.local.get(
['promptPattern', 'promptStyle', 'promptPersona', 'customPersona', 'promptFormat', 'promptModel'],
(saved) => {
selPattern.value = resolvePromptPattern(saved.promptPattern as string | undefined, saved.promptStyle as string | undefined);
updatePatternHint();
if (saved.promptPersona) {
selPersona.value = saved.promptPersona as string;
customRow.style.display = selPersona.value === 'Custom…' ? 'flex' : 'none';
}
if (saved.customPersona) customInput.value = saved.customPersona as string;
if (saved.promptFormat) selFormat.value = saved.promptFormat as string;
const savedModel = (saved.promptModel as string) || '';
sendToBackground({ type: 'LIST_MODELS' }).then((res) => {
const models = (res as { models?: string[] } | null)?.models;
if (!Array.isArray(models)) return; // keep just 'Default' on failure
models.forEach((m) => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
selModel.appendChild(opt);
});
if (savedModel && models.includes(savedModel)) selModel.value = savedModel;
});
},
);
// Buttons
const btnRow = document.createElement('div');
Object.assign(btnRow.style, { display: 'flex', gap: '8px', marginTop: '12px' });
const cancelBtn = document.createElement('button');
cancelBtn.textContent = 'Cancel';
cancelBtn.setAttribute('data-lexai', 'true');
Object.assign(cancelBtn.style, {
flex: '1', background: 'rgba(49,50,68,0.8)', color: '#a6adc8',
border: '1px solid rgba(205,214,244,0.15)', borderRadius: '8px',
padding: '8px', fontSize: '12px', fontWeight: '600', cursor: 'pointer',
});
cancelBtn.addEventListener('click', close);
const makeBtn = document.createElement('button');
makeBtn.textContent = '🪄 Make Prompt';
makeBtn.setAttribute('data-lexai', 'true');
Object.assign(makeBtn.style, {
flex: '2', background: 'linear-gradient(135deg, #f9e2af, #fab387)', color: '#1e1e2e',
border: 'none', borderRadius: '8px',
padding: '8px', fontSize: '12px', fontWeight: '700', cursor: 'pointer',
});
makeBtn.addEventListener('click', () => {
// Persist selections (shared with the popup), then run — the background
// reads these same keys for prompt requests without explicit params.
chrome.storage.local.set({
promptPattern: selPattern.value,
promptPersona: selPersona.value,
customPersona: customInput.value,
promptFormat: selFormat.value,
promptModel: selModel.value,
});
// Turn the dialog into the progress indicator — runAction closes it
// (closePromptBuilder) when the result modal takes over.
panel.innerHTML = '';
const loading = document.createElement('div');
loading.setAttribute('data-lexai', 'true');
loading.innerHTML =
'<span style="font-size:18px;animation:lexai-spin 0.8s linear infinite;display:inline-block">⟳</span> Building your prompt…';
Object.assign(loading.style, {
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: '8px', padding: '14px 0', color: '#a6adc8', fontSize: '13px',
});
panel.appendChild(loading);
runAction('prompt'); // keep selectionLocked — runAction owns it from here
});
btnRow.appendChild(cancelBtn);
btnRow.appendChild(makeBtn);
panel.appendChild(btnRow);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
document.body.appendChild(overlay);
}
// ─── Safe chrome.runtime.sendMessage wrapper ──────────────────────────────
// Shared implementation; on a stale extension context we toast and clean up.
async function sendToBackground(payload: Record<string, unknown>): Promise<unknown> {
return safeSendMessage(payload, () => {
showErrorToast('LexAI was updated — please refresh this page.');
hideToolbar();
closePromptBuilder();
});
}
// ─── LLM call ─────────────────────────────────────────────────────────────
async function runAction(action: string) {
if (!selectedText) return;
const textToProcess = selectedText;
// SNAPSHOT selection state RIGHT NOW before anything async happens.
// These are passed directly into showModal so the Replace handler has
// guaranteed access — no closure-scope ambiguity.
const snapStart = storedStart;
const snapEnd = storedEnd;
const snapElement = storedElement;
const snapRange = storedRange ? storedRange.cloneRange() : null;
debugLog('[LexAI runAction] snapshot captured', {
snapStart,
snapEnd,
snapElement: snapElement?.tagName,
snapElementId: snapElement?.id || snapElement?.name || '(no id)',
hasSnapRange: !!snapRange,
selectedText: textToProcess.substring(0, 50),
});
selectionLocked = true; // lock stored selection — modal interaction won't reset it
// Guard: extension may have been reloaded/updated
if (!isExtensionValid()) {
hideToolbar();
showErrorToast('LexAI was updated. Please refresh the page.');
return;
}
// Show loading in toolbar
if (toolbar) {
toolbar.innerHTML = '';
const loading = document.createElement('span');
loading.textContent = '⏳ LexAI thinking…';
loading.setAttribute('data-lexai', 'true');
Object.assign(loading.style, {
color: '#a6adc8',
fontSize: '12px',
padding: '4px 10px',
});
toolbar.appendChild(loading);
}
try {
const response = await sendToBackground({
type: 'ANALYZE_TEXT',
payload: { text: textToProcess, action, style: currentStyle },
}) as { error?: string; result?: string } | null;
hideToolbar();
closePromptBuilder();
if (response === null) return; // sendToBackground already handled the error
if (response?.error) {
showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange, action, textToProcess);
} else {
// Tone analysis is display-only — no Replace button (pass null for originalText)
const isExplainAction = action === 'explain';
showModal(
response?.result ?? '(no result)',
isExplainAction ? null : textToProcess,
snapStart, snapEnd, snapElement, snapRange,
action, textToProcess,
);
}
} catch (err) {
hideToolbar();
closePromptBuilder();
if (String(err).includes('Extension context invalidated') ||
String(err).includes('message channel closed')) {
showErrorToast('LexAI was updated — please refresh this page.');
} else {
showModal(`❌ Error: ${String(err)}`, null, snapStart, snapEnd, snapElement, snapRange, action, textToProcess);
}
}
}
// ─── Result Modal ─────────────────────────────────────────────────────────
// snapStart/snapEnd/snapElement/snapRange are passed explicitly from runAction
// so the Replace handler has guaranteed in-scope access to them.
function showModal(
resultText: string,
originalText: string | null,
snapStart: number,
snapEnd: number,
snapElement: HTMLTextAreaElement | HTMLInputElement | null,
snapRange: Range | null,
action?: string,
textForRegenerate?: string,
) {
if (modal) modal.remove();
// Overlay
const overlay = document.createElement('div');
overlay.setAttribute('data-lexai', 'true');
Object.assign(overlay.style, {
position: 'fixed',
inset: '0',
zIndex: '2147483646',
background: 'rgba(0,0,0,0.3)',
backdropFilter: 'blur(2px)',
});
modal = document.createElement('div');
modal.setAttribute('data-lexai', 'true');
Object.assign(modal.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius:'14px',
padding: '20px 22px',
width: '420px',
maxWidth: 'min(90vw, 420px)',
maxHeight: '70vh',
// The result body scrolls, not the modal — header and action buttons
// must stay visible for long results (e.g. engineered prompts).
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 12px 48px rgba(0,0,0,0.6)',
border: '1px solid rgba(205,214,244,0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: '#cdd6f4',
});
// Header
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
justifyContent:'space-between',
alignItems: 'center',
marginBottom: '12px',
flexShrink: '0',
});
const isPromptAction = action === 'prompt';
const title = document.createElement('div');
title.innerHTML = isPromptAction
? '🪄 <strong>LexAI</strong> Engineered Prompt'
: '⚡ <strong>LexAI</strong> Suggestion';
Object.assign(title.style, { fontSize: '13px', color: '#89b4fa' });
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
Object.assign(closeBtn.style, {
background: 'none',
border: 'none',
color: '#6c7086',
cursor: 'pointer',
fontSize: '16px',
padding: '0 4px',
lineHeight: '1',
});
closeBtn.addEventListener('click', () => {
selectionLocked = false;
overlay.remove();
modal!.remove();
modal = null;
});
header.appendChild(title);
header.appendChild(closeBtn);
modal.appendChild(header);
// Result text
const body = document.createElement('div');
body.textContent = resultText;
Object.assign(body.style, {
fontSize: '14px',
lineHeight: '1.65',
color: '#cdd6f4',
background: 'rgba(49,50,68,0.5)',
borderRadius:'8px',
padding: '12px 14px',
marginBottom:'14px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
// Scroll long results inside the box; flex keeps buttons below visible.
overflowY: 'auto',
flex: '1 1 auto',
minHeight: '60px',
});
modal.appendChild(body);
// ─── Style selector row ───────────────────────────────────────────────
const STYLES = WRITING_STYLES;
const styleRow = document.createElement('div');
Object.assign(styleRow.style, {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom:'12px',
flexShrink: '0',
});
const styleLabel = document.createElement('span');
styleLabel.textContent = 'Style:';
Object.assign(styleLabel.style, {
fontSize: '12px',
color: '#a6adc8',
flexShrink: '0',
});
const styleSelect = document.createElement('select');
Object.assign(styleSelect.style, {
background: 'rgba(49,50,68,0.9)',
color: '#cdd6f4',
border: '1px solid rgba(205,214,244,0.2)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '12px',
cursor: 'pointer',
flex: '1',
outline: 'none',
});
STYLES.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
styleSelect.appendChild(opt);
});
// Load saved style and pre-select
chrome.storage.local.get(['writingStyle'], (res) => {
const saved = (res.writingStyle as string) || 'Default';
styleSelect.value = saved;
currentStyle = saved;
});
const regenBtn = document.createElement('button');
regenBtn.textContent = '↺ Regenerate';
regenBtn.setAttribute('data-lexai', 'true');
Object.assign(regenBtn.style, {
background: 'rgba(137,180,250,0.15)',
color: '#89b4fa',
border: '1px solid rgba(137,180,250,0.3)',
borderRadius: '6px',
padding: '5px 10px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
flexShrink: '0',
whiteSpace: 'nowrap',
});
async function doRegenerate() {
if (!action || !textForRegenerate) return;
let chosenStyle = currentStyle;
// The writing-style selector only exists for non-prompt results; prompt
// regeneration re-reads the saved Prompt Builder params in the background.
if (!isPromptAction) {
chosenStyle = styleSelect.value;
currentStyle = chosenStyle;
await chrome.storage.local.set({ writingStyle: chosenStyle });
}
// Show spinner
body.innerHTML = '<span style="font-size:18px;animation:lexai-spin 0.8s linear infinite;display:inline-block">⟳</span> Regenerating…';
regenBtn.disabled = true;
styleSelect.disabled = true;
const response = await sendToBackground({
type: 'ANALYZE_TEXT',
payload: { text: textForRegenerate, action, style: chosenStyle },
}) as { error?: string; result?: string } | null;
regenBtn.disabled = false;
styleSelect.disabled = false;
if (!response || response.error) {
body.textContent = `${response?.error ?? 'Unknown error'}`;
} else {
body.textContent = response.result ?? '(no result)';
}
}
styleSelect.addEventListener('change', () => doRegenerate());
regenBtn.addEventListener('click', () => doRegenerate());
if (isPromptAction) {
// Prompt results: writing styles don't apply — offer the Prompt Builder
// parameters instead, plus regenerate with the current ones.
const editBtn = document.createElement('button');
editBtn.textContent = '✎ Edit Parameters';
editBtn.setAttribute('data-lexai', 'true');
Object.assign(editBtn.style, {
background: 'rgba(249,226,175,0.15)',
color: '#f9e2af',
border: '1px solid rgba(249,226,175,0.3)',
borderRadius: '6px',
padding: '5px 10px',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
flex: '1',
});
editBtn.addEventListener('click', () => {
overlay.remove();
modal?.remove();
modal = null;
showPromptBuilderDialog(); // selection stays locked; dialog re-runs from here
});
styleRow.appendChild(editBtn);
styleRow.appendChild(regenBtn);
modal.appendChild(styleRow);
} else {
styleRow.appendChild(styleLabel);
styleRow.appendChild(styleSelect);
styleRow.appendChild(regenBtn);
modal.appendChild(styleRow);
}
// Buttons
const btnRow = document.createElement('div');
Object.assign(btnRow.style, { display: 'flex', gap: '8px', flexShrink: '0' });
if (originalText !== null) {
const replaceBtn = document.createElement('button');
replaceBtn.textContent = '↩ Replace';
replaceBtn.setAttribute('data-lexai', 'true');
Object.assign(replaceBtn.style, {
background: '#89b4fa',
color: '#1e1e2e',
border: 'none',
borderRadius:'8px',
padding: '8px 18px',
fontSize: '13px',
fontWeight: '700',
cursor: 'pointer',
flex: '1',
});
replaceBtn.addEventListener('mousedown', (e) => e.preventDefault());
replaceBtn.addEventListener('click', () => {
// Debug log — confirms exactly what state we have at replace time
debugLog('[LexAI Replace]', {
snapStart,
snapEnd,
snapElement: snapElement?.tagName ?? 'null',
snapElementValue: snapElement?.value?.substring(0, 50) ?? 'N/A',
hasSnapRange: !!snapRange,
resultText: resultText.substring(0, 50),
});
if (snapElement && snapStart >= 0 && snapEnd >= 0 && snapEnd > snapStart) {
// textarea / input path — use the snapshot indices directly
const before = snapElement.value.substring(0, snapStart);
const after = snapElement.value.substring(snapEnd);
debugLog('[LexAI Replace] before:', JSON.stringify(before.substring(0, 30)));
debugLog('[LexAI Replace] after:', JSON.stringify(after.substring(0, 30)));
snapElement.value = before + resultText + after;
snapElement.setSelectionRange(snapStart, snapStart + resultText.length);
snapElement.dispatchEvent(new Event('input', { bubbles: true }));
snapElement.dispatchEvent(new Event('change', { bubbles: true }));
snapElement.focus();
} else if (snapRange) {
// contenteditable / DOM range path
// Strategy: restore the original selection, then just "type" the new text.
// The browser automatically replaces selected text on insert — no manual
// deleteContents() needed (and safer since stale range deletes can go wrong).
debugLog('[LexAI Replace] using DOM range — restoring selection + inserting');
try {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(snapRange); // restore original highlighted selection
}
document.execCommand('insertText', false, resultText); // replaces selection
} catch (err) {
debugWarn('[LexAI Replace] execCommand failed:', err);
}
} else {
debugWarn('[LexAI Replace] NO VALID SNAP STATE — snapStart:', snapStart, 'snapEnd:', snapEnd, 'snapElement:', snapElement, 'snapRange:', snapRange);
}
selectionLocked = false;
overlay.remove();
modal!.remove();
modal = null;
});
btnRow.appendChild(replaceBtn);
}
const copyBtn = document.createElement('button');
copyBtn.textContent = '⎘ Copy';
Object.assign(copyBtn.style, {
background: 'rgba(49,50,68,0.8)',
color: '#cdd6f4',
border: '1px solid rgba(205,214,244,0.1)',
borderRadius:'8px',
padding: '8px 18px',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer',
});
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText).then(() => {
copyBtn.textContent = '✓ Copied!';
setTimeout(() => { copyBtn.textContent = '⎘ Copy'; }, 1500);
});
});
btnRow.appendChild(copyBtn);
const dismissBtn = document.createElement('button');
dismissBtn.textContent = 'Dismiss';
Object.assign(dismissBtn.style, {
background: 'rgba(49,50,68,0.8)',
color: '#6c7086',
border: '1px solid rgba(205,214,244,0.1)',
borderRadius:'8px',
padding: '8px 14px',
fontSize: '13px',
cursor: 'pointer',
});
dismissBtn.addEventListener('click', () => {
selectionLocked = false; // unlock on dismiss
overlay.remove();
modal!.remove();
modal = null;
});
btnRow.appendChild(dismissBtn);
modal.appendChild(btnRow);
// Close overlay on click
overlay.addEventListener('click', () => {
selectionLocked = false;
overlay.remove();
modal!.remove();
modal = null;
});
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
// ─── Global extension context error handlers ──────────────────────────────
window.addEventListener('error', (e) => {
if (e.message?.includes('Extension context invalidated')) {
e.preventDefault(); // suppress console error
hideToolbar();
showErrorToast('LexAI was updated — please refresh this page.');
}
});
window.addEventListener('unhandledrejection', (e) => {
if (String(e.reason)?.includes('Extension context invalidated') ||
String(e.reason)?.includes('message channel closed')) {
e.preventDefault();
hideToolbar();
showErrorToast('LexAI was updated — please refresh this page.');
}
});
// ─── Event listeners ──────────────────────────────────────────────────────
document.addEventListener('mouseup', (e) => {
const target = e.target as Element;
// Don't trigger on our own UI
if (target?.closest?.('[data-lexai="true"]')) return;
// Capture selection state IMMEDIATELY — positions are valid right now.
// The setTimeout below lets the browser finalise the selection before we read it.
const captured = captureSelectionNow();
setTimeout(() => {
if (!captured || !selectedText || selectedText.length < MIN_SELECTION_LENGTH) {
hideToolbar();
return;
}
// Get bounding rect from the live selection (still valid inside setTimeout)
let rect: DOMRect | null = null;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
rect = sel.getRangeAt(0).getBoundingClientRect();
} else if (storedElement) {
// fallback: use the element's bounding rect for textarea/input
rect = storedElement.getBoundingClientRect();
}
if (rect && rect.width > 0) {
showToolbar(rect);
} else {
hideToolbar();
}
}, 10);
});
// Hide toolbar when clicking elsewhere (not on our UI)
document.addEventListener('mousedown', (e) => {
const target = e.target as Element;
if (!target?.closest?.('[data-lexai="true"]')) {
hideToolbar();
}
});
// Hide toolbar on scroll
document.addEventListener('scroll', () => {
hideToolbar();
}, { passive: true });
// Keyboard: Escape closes both
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
hideToolbar();
if (modal) {
selectionLocked = false;
modal.remove();
modal = null;
document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove());
}
}
});
// ─── Context menu trigger from background ─────────────────────────────────
chrome.runtime.onMessage.addListener((message, sender) => {
// Only our own background worker may inject selections — ignore others.
if (sender.id !== chrome.runtime.id) return;
if (message.type === 'lexai-context-menu') {
selectedText = message.text as string;
currentStyle = (message.style as string) || 'Default';
// For context menu, we have no DOM selection positions — zero them out
storedStart = -1;
storedEnd = -1;
storedElement = null;
storedRange = null;
// Prompt goes through the Prompt Builder dialog like the toolbar button.
if (message.action === 'prompt') {
showPromptBuilderDialog();
} else {
runAction(message.action as string);
}
}
});
},
});