feat: fix WXT setup — install @wxt-dev/module-react, fix sandbox imports, clean build (closes #1)
This commit is contained in:
@@ -1,66 +1,186 @@
|
||||
import { defineContentScript } from 'wxt/sandbox';
|
||||
import { defineContentScript } from 'wxt/utils/define-content-script';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
main() {
|
||||
console.log('LexAI content script loaded');
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────
|
||||
let toolbar: HTMLElement | null = null;
|
||||
let modal: HTMLElement | null = null;
|
||||
let selectedText = '';
|
||||
let activeElement: Element | null = null;
|
||||
let selectionStart = 0;
|
||||
let selectionEnd = 0;
|
||||
let savedRange: Range | null = null;
|
||||
|
||||
// Listen for text selection
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 10) {
|
||||
selectedText = selection.toString().trim();
|
||||
showToolbar(e.clientX, e.clientY);
|
||||
} else {
|
||||
hideToolbar();
|
||||
// ─── Selection helpers ────────────────────────────────────────────────────
|
||||
|
||||
function captureSelection(): boolean {
|
||||
const sel = window.getSelection();
|
||||
|
||||
// 1) textarea / input
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
if (end - start > 0) {
|
||||
selectedText = el.value.slice(start, end).trim();
|
||||
if (selectedText.length >= 2) {
|
||||
activeElement = el;
|
||||
selectionStart = start;
|
||||
selectionEnd = end;
|
||||
savedRange = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function showToolbar(x: number, y: number) {
|
||||
// 2) contenteditable / regular DOM
|
||||
if (sel && sel.toString().trim().length >= 2) {
|
||||
selectedText = sel.toString().trim();
|
||||
activeElement = null;
|
||||
selectionStart = 0;
|
||||
selectionEnd = 0;
|
||||
savedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function replaceText(newText: string) {
|
||||
// textarea / input path
|
||||
if (
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement instanceof HTMLInputElement
|
||||
) {
|
||||
const el = activeElement;
|
||||
const before = el.value.slice(0, selectionStart);
|
||||
const after = el.value.slice(selectionEnd);
|
||||
el.value = before + newText + after;
|
||||
el.selectionStart = selectionStart;
|
||||
el.selectionEnd = selectionStart + newText.length;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM range path (contenteditable etc.)
|
||||
const range = savedRange;
|
||||
if (range) {
|
||||
try {
|
||||
range.deleteContents();
|
||||
const textNode = document.createTextNode(newText);
|
||||
range.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 (_) {
|
||||
// fallback: execCommand
|
||||
document.execCommand('insertText', false, newText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Toolbar ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getToolbarPosition(mouseX: number, mouseY: number) {
|
||||
const TOOLBAR_W = 280;
|
||||
const TOOLBAR_H = 40;
|
||||
const MARGIN = 8;
|
||||
const vpW = window.innerWidth;
|
||||
const vpH = window.innerHeight;
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Try to place above the cursor
|
||||
let top = mouseY + scrollY - TOOLBAR_H - MARGIN;
|
||||
let left = mouseX + scrollX - TOOLBAR_W / 2;
|
||||
|
||||
// Clamp horizontally
|
||||
left = Math.max(scrollX + MARGIN, Math.min(left, scrollX + vpW - TOOLBAR_W - MARGIN));
|
||||
// If above viewport, place below
|
||||
if (top < scrollY + MARGIN) top = mouseY + scrollY + MARGIN + 16;
|
||||
// If below viewport fold, try above again
|
||||
if (top + TOOLBAR_H > scrollY + vpH - MARGIN) top = mouseY + scrollY - TOOLBAR_H - MARGIN - 16;
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
function showToolbar(mouseX: number, mouseY: number) {
|
||||
hideToolbar();
|
||||
|
||||
const { top, left } = getToolbarPosition(mouseX, mouseY);
|
||||
|
||||
toolbar = document.createElement('div');
|
||||
toolbar.id = 'lexai-toolbar';
|
||||
toolbar.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${y - 50}px;
|
||||
left: ${x}px;
|
||||
z-index: 999999;
|
||||
background: #1e1e2e;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
`;
|
||||
toolbar.setAttribute('data-lexai', 'true');
|
||||
|
||||
const actions = [
|
||||
{ label: '✓ Fix', action: 'grammar' },
|
||||
{ label: '↺ Rephrase', action: 'rephrase' },
|
||||
{ label: '↓ Shorten', action: 'shorten' },
|
||||
{ label: '↑ Expand', action: 'expand' },
|
||||
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' },
|
||||
];
|
||||
|
||||
actions.forEach(({ label, action }) => {
|
||||
actions.forEach(({ label, action, color }) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.cssText = `
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
`;
|
||||
btn.addEventListener('mouseenter', () => btn.style.background = '#45475a');
|
||||
btn.addEventListener('mouseleave', () => btn.style.background = '#313244');
|
||||
btn.addEventListener('click', () => runAction(action));
|
||||
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()); // don't lose selection
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
runAction(action);
|
||||
});
|
||||
|
||||
toolbar!.appendChild(btn);
|
||||
});
|
||||
|
||||
@@ -74,70 +194,252 @@ export default defineContentScript({
|
||||
}
|
||||
}
|
||||
|
||||
// ─── LLM call ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function runAction(action: string) {
|
||||
if (!selectedText) return;
|
||||
const textToProcess = selectedText;
|
||||
|
||||
// Show loading state
|
||||
// Show loading in toolbar
|
||||
if (toolbar) {
|
||||
toolbar.innerHTML = '<span style="color:#cdd6f4;font-size:12px;padding:4px 8px;">⏳ LexAI thinking...</span>';
|
||||
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);
|
||||
}
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'ANALYZE_TEXT',
|
||||
payload: { text: selectedText, action },
|
||||
});
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'ANALYZE_TEXT',
|
||||
payload: { text: textToProcess, action },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
showResult(`❌ ${response.error}`);
|
||||
} else {
|
||||
showResult(response.result, true);
|
||||
hideToolbar();
|
||||
|
||||
if (response?.error) {
|
||||
showModal(`❌ ${response.error}`, null);
|
||||
} else {
|
||||
showModal(response?.result ?? '(no result)', textToProcess);
|
||||
}
|
||||
} catch (err) {
|
||||
hideToolbar();
|
||||
showModal(`❌ Error: ${String(err)}`, null);
|
||||
}
|
||||
}
|
||||
|
||||
function showResult(text: string, canReplace = false) {
|
||||
hideToolbar();
|
||||
// ─── Result Modal ─────────────────────────────────────────────────────────
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 999999;
|
||||
background: #1e1e2e;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: #cdd6f4;
|
||||
`;
|
||||
function showModal(resultText: string, originalText: string | null) {
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal.innerHTML = `
|
||||
<div style="font-size:11px;color:#a6adc8;margin-bottom:8px;">LexAI Suggestion</div>
|
||||
<div style="font-size:14px;line-height:1.6;margin-bottom:12px;">${text}</div>
|
||||
${canReplace ? '<button id="lexai-replace" style="background:#89b4fa;color:#1e1e2e;border:none;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer;margin-right:8px;">Replace</button>' : ''}
|
||||
<button id="lexai-close" style="background:#313244;color:#cdd6f4;border:none;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer;">Close</button>
|
||||
`;
|
||||
// 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',
|
||||
overflowY: 'auto',
|
||||
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',
|
||||
});
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.innerHTML = '⚡ <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', () => {
|
||||
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',
|
||||
});
|
||||
modal.appendChild(body);
|
||||
|
||||
// Buttons
|
||||
const btnRow = document.createElement('div');
|
||||
Object.assign(btnRow.style, { display: 'flex', gap: '8px' });
|
||||
|
||||
if (originalText !== null) {
|
||||
const replaceBtn = document.createElement('button');
|
||||
replaceBtn.textContent = '↩ Replace';
|
||||
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('click', () => {
|
||||
replaceText(resultText);
|
||||
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', () => {
|
||||
overlay.remove();
|
||||
modal!.remove();
|
||||
modal = null;
|
||||
});
|
||||
btnRow.appendChild(dismissBtn);
|
||||
|
||||
modal.appendChild(btnRow);
|
||||
|
||||
// Close overlay on click
|
||||
overlay.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
modal!.remove();
|
||||
modal = null;
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('lexai-close')?.addEventListener('click', () => modal.remove());
|
||||
document.getElementById('lexai-replace')?.addEventListener('click', () => {
|
||||
replaceSelectedText(text);
|
||||
modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function replaceSelectedText(newText: string) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(newText));
|
||||
selection.removeAllRanges();
|
||||
// ─── 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;
|
||||
|
||||
// Small delay to let browser finalize selection
|
||||
setTimeout(() => {
|
||||
if (captureSelection()) {
|
||||
showToolbar(e.clientX + window.scrollX, e.clientY + window.scrollY);
|
||||
} else {
|
||||
hideToolbar();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
modal.remove();
|
||||
modal = null;
|
||||
// also remove overlay
|
||||
document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user