fix: replace clears text field - store selection before toolbar shows
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled

This commit is contained in:
Forge
2026-03-06 13:38:13 +08:00
parent 3b1386bb4f
commit aa75e8d8ac

View File

@@ -8,71 +8,77 @@ export default defineContentScript({
// ─── State ─────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────
let toolbar: HTMLElement | null = null; let toolbar: HTMLElement | null = null;
let modal: HTMLElement | null = null; let modal: HTMLElement | null = null;
// Stored immediately on mouseup — used during Replace (selection is lost by then)
let selectedText = ''; let selectedText = '';
let activeElement: Element | null = null; let storedStart = -1;
let selectionStart = 0; let storedEnd = -1;
let selectionEnd = 0; let storedElement: HTMLTextAreaElement | HTMLInputElement | null = null;
let savedRange: Range | null = null; let storedRange: Range | null = null;
// ─── Selection helpers ──────────────────────────────────────────────────── // ─── Selection capture (called immediately on mouseup) ────────────────────
function captureSelection(): boolean { function captureSelectionNow(): boolean {
const sel = window.getSelection();
// 1) textarea / input
const el = document.activeElement; const el = document.activeElement;
// 1) textarea / input — grab positions while they're still valid
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) { if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) {
const start = el.selectionStart ?? 0; const start = el.selectionStart ?? -1;
const end = el.selectionEnd ?? 0; const end = el.selectionEnd ?? -1;
if (end - start > 0) { if (end - start >= 2) {
selectedText = el.value.slice(start, end).trim(); const text = el.value.substring(start, end).trim();
if (selectedText.length >= 2) { if (text.length >= 2) {
activeElement = el; selectedText = text;
selectionStart = start; storedStart = start;
selectionEnd = end; storedEnd = end;
savedRange = null; storedElement = el;
storedRange = null;
return true; return true;
} }
} }
return false;
} }
// 2) contenteditable / regular DOM // 2) contenteditable / regular DOM
if (sel && sel.toString().trim().length >= 2) { const sel = window.getSelection();
selectedText = sel.toString().trim(); if (sel && sel.rangeCount > 0) {
activeElement = null; const text = sel.toString().trim();
selectionStart = 0; if (text.length >= 2) {
selectionEnd = 0; selectedText = text;
savedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null; storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later
return true; storedElement = null;
storedStart = -1;
storedEnd = -1;
return true;
}
} }
return false; return false;
} }
// ─── Replace using stored state ───────────────────────────────────────────
function replaceText(newText: string) { function replaceText(newText: string) {
// textarea / input path // textarea / input path
if ( if (storedElement) {
activeElement instanceof HTMLTextAreaElement || const el = storedElement;
activeElement instanceof HTMLInputElement const before = el.value.substring(0, storedStart);
) { const after = el.value.substring(storedEnd);
const el = activeElement;
const before = el.value.slice(0, selectionStart);
const after = el.value.slice(selectionEnd);
el.value = before + newText + after; el.value = before + newText + after;
el.selectionStart = selectionStart; el.setSelectionRange(storedStart, storedStart + newText.length);
el.selectionEnd = selectionStart + newText.length; el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
el.focus(); el.focus();
return; return;
} }
// DOM range path (contenteditable etc.) // DOM range path (contenteditable etc.)
const range = savedRange; if (storedRange) {
if (range) {
try { try {
range.deleteContents(); storedRange.deleteContents();
const textNode = document.createTextNode(newText); const textNode = document.createTextNode(newText);
range.insertNode(textNode); storedRange.insertNode(textNode);
// move caret after inserted text // move caret after inserted text
const sel = window.getSelection(); const sel = window.getSelection();
if (sel) { if (sel) {
@@ -83,8 +89,9 @@ export default defineContentScript({
sel.addRange(newRange); sel.addRange(newRange);
} }
} catch (_) { } catch (_) {
// fallback: execCommand
document.execCommand('insertText', false, newText); document.execCommand('insertText', false, newText);
} finally {
storedRange = null;
} }
} }
} }
@@ -94,21 +101,17 @@ export default defineContentScript({
function getToolbarPosition(mouseX: number, mouseY: number) { function getToolbarPosition(mouseX: number, mouseY: number) {
const TOOLBAR_W = 280; const TOOLBAR_W = 280;
const TOOLBAR_H = 40; const TOOLBAR_H = 40;
const MARGIN = 8; const MARGIN = 8;
const vpW = window.innerWidth; const vpW = window.innerWidth;
const vpH = window.innerHeight; const vpH = window.innerHeight;
const scrollX = window.scrollX; const scrollX = window.scrollX;
const scrollY = window.scrollY; const scrollY = window.scrollY;
// Try to place above the cursor let top = mouseY + scrollY - TOOLBAR_H - MARGIN;
let top = mouseY + scrollY - TOOLBAR_H - MARGIN;
let left = mouseX + scrollX - TOOLBAR_W / 2; let left = mouseX + scrollX - TOOLBAR_W / 2;
// Clamp horizontally
left = Math.max(scrollX + MARGIN, Math.min(left, scrollX + vpW - TOOLBAR_W - MARGIN)); 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 (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; if (top + TOOLBAR_H > scrollY + vpH - MARGIN) top = mouseY + scrollY - TOOLBAR_H - MARGIN - 16;
return { top, left }; return { top, left };
@@ -124,19 +127,19 @@ export default defineContentScript({
toolbar.setAttribute('data-lexai', 'true'); toolbar.setAttribute('data-lexai', 'true');
Object.assign(toolbar.style, { Object.assign(toolbar.style, {
position: 'absolute', position: 'absolute',
top: `${top}px`, top: `${top}px`,
left: `${left}px`, left: `${left}px`,
zIndex: '2147483647', zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)', background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius: '10px', borderRadius:'10px',
padding: '6px 8px', padding: '6px 8px',
display: 'flex', display: 'flex',
gap: '4px', gap: '4px',
boxShadow: '0 4px 24px rgba(0,0,0,0.45), 0 1px 3px rgba(0,0,0,0.3)', 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)', border: '1px solid rgba(205,214,244,0.12)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
alignItems: 'center', alignItems: 'center',
}); });
const actions: { label: string; action: string; color: string }[] = [ const actions: { label: string; action: string; color: string }[] = [
@@ -152,28 +155,28 @@ export default defineContentScript({
btn.setAttribute('data-lexai', 'true'); btn.setAttribute('data-lexai', 'true');
Object.assign(btn.style, { Object.assign(btn.style, {
background: 'rgba(49,50,68,0.8)', background: 'rgba(49,50,68,0.8)',
color: color, color: color,
border: '1px solid rgba(205,214,244,0.1)', border: '1px solid rgba(205,214,244,0.1)',
borderRadius: '7px', borderRadius: '7px',
padding: '4px 10px', padding: '4px 10px',
fontSize: '12px', fontSize: '12px',
fontWeight: '600', fontWeight: '600',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
letterSpacing: '0.01em', letterSpacing: '0.01em',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}); });
btn.addEventListener('mouseenter', () => { btn.addEventListener('mouseenter', () => {
btn.style.background = 'rgba(69,71,90,0.9)'; btn.style.background = 'rgba(69,71,90,0.9)';
btn.style.borderColor = color + '50'; btn.style.borderColor = color + '50';
btn.style.transform = 'translateY(-1px)'; btn.style.transform = 'translateY(-1px)';
}); });
btn.addEventListener('mouseleave', () => { btn.addEventListener('mouseleave', () => {
btn.style.background = 'rgba(49,50,68,0.8)'; btn.style.background = 'rgba(49,50,68,0.8)';
btn.style.borderColor = 'rgba(205,214,244,0.1)'; btn.style.borderColor = 'rgba(205,214,244,0.1)';
btn.style.transform = 'translateY(0)'; btn.style.transform = 'translateY(0)';
}); });
btn.addEventListener('mousedown', (e) => e.preventDefault()); // don't lose selection btn.addEventListener('mousedown', (e) => e.preventDefault()); // don't lose selection
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -207,16 +210,16 @@ export default defineContentScript({
loading.textContent = '⏳ LexAI thinking…'; loading.textContent = '⏳ LexAI thinking…';
loading.setAttribute('data-lexai', 'true'); loading.setAttribute('data-lexai', 'true');
Object.assign(loading.style, { Object.assign(loading.style, {
color: '#a6adc8', color: '#a6adc8',
fontSize: '12px', fontSize: '12px',
padding: '4px 10px', padding: '4px 10px',
}); });
toolbar.appendChild(loading); toolbar.appendChild(loading);
} }
try { try {
const response = await chrome.runtime.sendMessage({ const response = await chrome.runtime.sendMessage({
type: 'ANALYZE_TEXT', type: 'ANALYZE_TEXT',
payload: { text: textToProcess, action }, payload: { text: textToProcess, action },
}); });
@@ -242,41 +245,41 @@ export default defineContentScript({
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.setAttribute('data-lexai', 'true'); overlay.setAttribute('data-lexai', 'true');
Object.assign(overlay.style, { Object.assign(overlay.style, {
position: 'fixed', position: 'fixed',
inset: '0', inset: '0',
zIndex: '2147483646', zIndex: '2147483646',
background: 'rgba(0,0,0,0.3)', background: 'rgba(0,0,0,0.3)',
backdropFilter: 'blur(2px)', backdropFilter: 'blur(2px)',
}); });
modal = document.createElement('div'); modal = document.createElement('div');
modal.setAttribute('data-lexai', 'true'); modal.setAttribute('data-lexai', 'true');
Object.assign(modal.style, { Object.assign(modal.style, {
position: 'fixed', position: 'fixed',
top: '50%', top: '50%',
left: '50%', left: '50%',
transform: 'translate(-50%,-50%)', transform: 'translate(-50%,-50%)',
zIndex: '2147483647', zIndex: '2147483647',
background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)', background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)',
borderRadius: '14px', borderRadius:'14px',
padding: '20px 22px', padding: '20px 22px',
width: '420px', width: '420px',
maxWidth: 'min(90vw, 420px)', maxWidth: 'min(90vw, 420px)',
maxHeight: '70vh', maxHeight: '70vh',
overflowY: 'auto', overflowY: 'auto',
boxShadow: '0 12px 48px rgba(0,0,0,0.6)', boxShadow: '0 12px 48px rgba(0,0,0,0.6)',
border: '1px solid rgba(205,214,244,0.15)', border: '1px solid rgba(205,214,244,0.15)',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
color: '#cdd6f4', color: '#cdd6f4',
}); });
// Header // Header
const header = document.createElement('div'); const header = document.createElement('div');
Object.assign(header.style, { Object.assign(header.style, {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent:'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: '12px', marginBottom: '12px',
}); });
const title = document.createElement('div'); const title = document.createElement('div');
@@ -287,11 +290,11 @@ export default defineContentScript({
closeBtn.textContent = '✕'; closeBtn.textContent = '✕';
Object.assign(closeBtn.style, { Object.assign(closeBtn.style, {
background: 'none', background: 'none',
border: 'none', border: 'none',
color: '#6c7086', color: '#6c7086',
cursor: 'pointer', cursor: 'pointer',
fontSize: '16px', fontSize: '16px',
padding: '0 4px', padding: '0 4px',
lineHeight: '1', lineHeight: '1',
}); });
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
@@ -308,15 +311,15 @@ export default defineContentScript({
const body = document.createElement('div'); const body = document.createElement('div');
body.textContent = resultText; body.textContent = resultText;
Object.assign(body.style, { Object.assign(body.style, {
fontSize: '14px', fontSize: '14px',
lineHeight: '1.65', lineHeight: '1.65',
color: '#cdd6f4', color: '#cdd6f4',
background: 'rgba(49,50,68,0.5)', background: 'rgba(49,50,68,0.5)',
borderRadius: '8px', borderRadius:'8px',
padding: '12px 14px', padding: '12px 14px',
marginBottom: '14px', marginBottom:'14px',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}); });
modal.appendChild(body); modal.appendChild(body);
@@ -327,17 +330,19 @@ export default defineContentScript({
if (originalText !== null) { if (originalText !== null) {
const replaceBtn = document.createElement('button'); const replaceBtn = document.createElement('button');
replaceBtn.textContent = '↩ Replace'; replaceBtn.textContent = '↩ Replace';
replaceBtn.setAttribute('data-lexai', 'true');
Object.assign(replaceBtn.style, { Object.assign(replaceBtn.style, {
background: '#89b4fa', background: '#89b4fa',
color: '#1e1e2e', color: '#1e1e2e',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius:'8px',
padding: '8px 18px', padding: '8px 18px',
fontSize: '13px', fontSize: '13px',
fontWeight: '700', fontWeight: '700',
cursor: 'pointer', cursor: 'pointer',
flex: '1', flex: '1',
}); });
replaceBtn.addEventListener('mousedown', (e) => e.preventDefault()); // keep stored selection intact
replaceBtn.addEventListener('click', () => { replaceBtn.addEventListener('click', () => {
replaceText(resultText); replaceText(resultText);
overlay.remove(); overlay.remove();
@@ -350,14 +355,14 @@ export default defineContentScript({
const copyBtn = document.createElement('button'); const copyBtn = document.createElement('button');
copyBtn.textContent = '⎘ Copy'; copyBtn.textContent = '⎘ Copy';
Object.assign(copyBtn.style, { Object.assign(copyBtn.style, {
background: 'rgba(49,50,68,0.8)', background: 'rgba(49,50,68,0.8)',
color: '#cdd6f4', color: '#cdd6f4',
border: '1px solid rgba(205,214,244,0.1)', border: '1px solid rgba(205,214,244,0.1)',
borderRadius: '8px', borderRadius:'8px',
padding: '8px 18px', padding: '8px 18px',
fontSize: '13px', fontSize: '13px',
fontWeight: '600', fontWeight: '600',
cursor: 'pointer', cursor: 'pointer',
}); });
copyBtn.addEventListener('click', () => { copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(resultText).then(() => { navigator.clipboard.writeText(resultText).then(() => {
@@ -370,13 +375,13 @@ export default defineContentScript({
const dismissBtn = document.createElement('button'); const dismissBtn = document.createElement('button');
dismissBtn.textContent = 'Dismiss'; dismissBtn.textContent = 'Dismiss';
Object.assign(dismissBtn.style, { Object.assign(dismissBtn.style, {
background: 'rgba(49,50,68,0.8)', background: 'rgba(49,50,68,0.8)',
color: '#6c7086', color: '#6c7086',
border: '1px solid rgba(205,214,244,0.1)', border: '1px solid rgba(205,214,244,0.1)',
borderRadius: '8px', borderRadius:'8px',
padding: '8px 14px', padding: '8px 14px',
fontSize: '13px', fontSize: '13px',
cursor: 'pointer', cursor: 'pointer',
}); });
dismissBtn.addEventListener('click', () => { dismissBtn.addEventListener('click', () => {
overlay.remove(); overlay.remove();
@@ -406,9 +411,12 @@ export default defineContentScript({
// Don't trigger on our own UI // Don't trigger on our own UI
if (target?.closest?.('[data-lexai="true"]')) return; if (target?.closest?.('[data-lexai="true"]')) return;
// Small delay to let browser finalize selection // Capture selection state IMMEDIATELY — positions are valid right now.
// The setTimeout below is only for showing the toolbar (lets browser finalise).
const captured = captureSelectionNow();
setTimeout(() => { setTimeout(() => {
if (captureSelection()) { if (captured && selectedText) {
showToolbar(e.clientX + window.scrollX, e.clientY + window.scrollY); showToolbar(e.clientX + window.scrollX, e.clientY + window.scrollY);
} else { } else {
hideToolbar(); hideToolbar();
@@ -436,7 +444,6 @@ export default defineContentScript({
if (modal) { if (modal) {
modal.remove(); modal.remove();
modal = null; modal = null;
// also remove overlay
document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove()); document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove());
} }
} }