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 ───────────────────────────────────────────────────────────────
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;
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.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;
}
}
}
@@ -100,15 +107,11 @@ export default defineContentScript({
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 };
@@ -327,6 +330,7 @@ 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',
@@ -338,6 +342,7 @@ export default defineContentScript({
cursor: 'pointer',
flex: '1',
});
replaceBtn.addEventListener('mousedown', (e) => e.preventDefault()); // keep stored selection intact
replaceBtn.addEventListener('click', () => {
replaceText(resultText);
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());
}
}