From aa75e8d8ac5635dbd6676763fd5da78ff0c114e8 Mon Sep 17 00:00:00 2001 From: Forge Date: Fri, 6 Mar 2026 13:38:13 +0800 Subject: [PATCH] fix: replace clears text field - store selection before toolbar shows --- entrypoints/content.ts | 293 +++++++++++++++++++++-------------------- 1 file changed, 150 insertions(+), 143 deletions(-) diff --git a/entrypoints/content.ts b/entrypoints/content.ts index b0cb877..0fef752 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -8,71 +8,77 @@ export default defineContentScript({ // ─── State ─────────────────────────────────────────────────────────────── let toolbar: HTMLElement | null = null; let modal: HTMLElement | null = null; + + // Stored immediately on mouseup — used during Replace (selection is lost by then) let selectedText = ''; - let activeElement: Element | null = null; - let selectionStart = 0; - let selectionEnd = 0; - let savedRange: Range | null = null; + let storedStart = -1; + let storedEnd = -1; + let storedElement: HTMLTextAreaElement | HTMLInputElement | null = null; + let storedRange: Range | null = null; - // ─── Selection helpers ──────────────────────────────────────────────────── + // ─── Selection capture (called immediately on mouseup) ──────────────────── - function captureSelection(): boolean { - const sel = window.getSelection(); - - // 1) textarea / input + function captureSelectionNow(): boolean { 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 ?? 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; + 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 >= 2) { + selectedText = text; + storedStart = start; + storedEnd = end; + storedElement = el; + storedRange = null; return true; } } + return false; } // 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; + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const text = sel.toString().trim(); + if (text.length >= 2) { + selectedText = text; + storedRange = sel.getRangeAt(0).cloneRange(); // CLONE — selection will be lost later + storedElement = null; + storedStart = -1; + storedEnd = -1; + return true; + } } return false; } + // ─── Replace using stored state ─────────────────────────────────────────── + 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); + if (storedElement) { + const el = storedElement; + const before = el.value.substring(0, storedStart); + const after = el.value.substring(storedEnd); el.value = before + newText + after; - el.selectionStart = selectionStart; - el.selectionEnd = selectionStart + newText.length; - el.dispatchEvent(new Event('input', { bubbles: true })); + 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.) - const range = savedRange; - if (range) { + if (storedRange) { try { - range.deleteContents(); + storedRange.deleteContents(); const textNode = document.createTextNode(newText); - range.insertNode(textNode); + storedRange.insertNode(textNode); + // move caret after inserted text const sel = window.getSelection(); if (sel) { @@ -83,8 +89,9 @@ export default defineContentScript({ sel.addRange(newRange); } } catch (_) { - // fallback: execCommand document.execCommand('insertText', false, newText); + } finally { + storedRange = null; } } } @@ -94,21 +101,17 @@ export default defineContentScript({ 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; + 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 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 < scrollY + MARGIN) top = mouseY + scrollY + MARGIN + 16; if (top + TOOLBAR_H > scrollY + vpH - MARGIN) top = mouseY + scrollY - TOOLBAR_H - MARGIN - 16; return { top, left }; @@ -124,19 +127,19 @@ export default defineContentScript({ 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', + 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 }[] = [ @@ -152,28 +155,28 @@ export default defineContentScript({ 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', + 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', + 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.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.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) => { @@ -207,16 +210,16 @@ export default defineContentScript({ loading.textContent = '⏳ LexAI thinking…'; loading.setAttribute('data-lexai', 'true'); Object.assign(loading.style, { - color: '#a6adc8', + color: '#a6adc8', fontSize: '12px', - padding: '4px 10px', + padding: '4px 10px', }); toolbar.appendChild(loading); } try { const response = await chrome.runtime.sendMessage({ - type: 'ANALYZE_TEXT', + type: 'ANALYZE_TEXT', payload: { text: textToProcess, action }, }); @@ -242,41 +245,41 @@ export default defineContentScript({ 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)', + 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', + 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)', + 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', + color: '#cdd6f4', }); // Header const header = document.createElement('div'); Object.assign(header.style, { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '12px', + display: 'flex', + justifyContent:'space-between', + alignItems: 'center', + marginBottom: '12px', }); const title = document.createElement('div'); @@ -287,11 +290,11 @@ export default defineContentScript({ closeBtn.textContent = '✕'; Object.assign(closeBtn.style, { background: 'none', - border: 'none', - color: '#6c7086', - cursor: 'pointer', - fontSize: '16px', - padding: '0 4px', + border: 'none', + color: '#6c7086', + cursor: 'pointer', + fontSize: '16px', + padding: '0 4px', lineHeight: '1', }); closeBtn.addEventListener('click', () => { @@ -308,15 +311,15 @@ export default defineContentScript({ const body = document.createElement('div'); body.textContent = resultText; Object.assign(body.style, { - fontSize: '14px', + fontSize: '14px', lineHeight: '1.65', - color: '#cdd6f4', + color: '#cdd6f4', background: 'rgba(49,50,68,0.5)', - borderRadius: '8px', - padding: '12px 14px', - marginBottom: '14px', + borderRadius:'8px', + padding: '12px 14px', + marginBottom:'14px', whiteSpace: 'pre-wrap', - wordBreak: 'break-word', + wordBreak: 'break-word', }); modal.appendChild(body); @@ -327,17 +330,19 @@ export default defineContentScript({ 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', + 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()); // keep stored selection intact replaceBtn.addEventListener('click', () => { replaceText(resultText); overlay.remove(); @@ -350,14 +355,14 @@ export default defineContentScript({ 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', + 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(() => { @@ -370,13 +375,13 @@ export default defineContentScript({ 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', + 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(); @@ -406,9 +411,12 @@ export default defineContentScript({ // Don't trigger on our own UI 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(() => { - if (captureSelection()) { + if (captured && selectedText) { showToolbar(e.clientX + window.scrollX, e.clientY + window.scrollY); } else { hideToolbar(); @@ -436,7 +444,6 @@ export default defineContentScript({ if (modal) { modal.remove(); modal = null; - // also remove overlay document.querySelectorAll('[data-lexai="true"]').forEach(el => el.remove()); } }