From 180c1a528ff71c164d340cd0159a213768c01ccd Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 16:29:21 -0500 Subject: [PATCH 1/9] feat: add API integration for external proofreading service * Introduced API configuration options in the settings, allowing users to connect to an external AI proofreading service. * Implemented functionality to test API connection and fetch available models. * Updated storage management to handle API configuration and model source selection. * Enhanced the background service to support API-based proofreading requests. * Added UI elements for API configuration in the options page. --- manifest.config.ts | 1 + src/background/main.ts | 61 +++++- src/background/proofreader-proxy.ts | 43 +++- src/options/api-config-section.ts | 243 +++++++++++++++++++++ src/options/main.ts | 177 ++++++++++++++-- src/options/style.css | 206 ++++++++++++++++++ src/services/api-proofreader.ts | 316 ++++++++++++++++++++++++++++ src/shared/constants.ts | 12 +- src/shared/messages/issues.ts | 23 +- src/shared/types.ts | 12 ++ src/shared/utils/storage.ts | 6 +- 11 files changed, 1079 insertions(+), 21 deletions(-) create mode 100644 src/options/api-config-section.ts create mode 100644 src/services/api-proofreader.ts diff --git a/manifest.config.ts b/manifest.config.ts index fab7e76..1f981f8 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -36,6 +36,7 @@ export default defineManifest({ }, ], permissions: ['sidePanel', 'tabs', 'storage', 'contextMenus'], + optional_host_permissions: ['https://*/*', 'http://*/*'], side_panel: { default_path: 'src/sidepanel/index.html', }, diff --git a/src/background/main.ts b/src/background/main.ts index 93efa3d..e0291ca 100644 --- a/src/background/main.ts +++ b/src/background/main.ts @@ -1,7 +1,12 @@ import { STORAGE_KEYS } from '../shared/constants.ts'; -import { initializeStorage, onStorageChange } from '../shared/utils/storage.ts'; +import { initializeStorage, onStorageChange, getStorageValue } from '../shared/utils/storage.ts'; import { logger } from '../services/logger.ts'; -import { handleProofreadRequest, resetProofreaderServices } from './proofreader-proxy.ts'; +import { + handleProofreadRequest, + resetProofreaderServices, + updateModelSourceCache, +} from './proofreader-proxy.ts'; +import { getApiProvider } from '../services/api-proofreader.ts'; import type { IssuesUpdatePayload, @@ -13,6 +18,8 @@ import type { ProofreadRequestMessage, ProofreaderBusyStateRequestMessage, ProofreaderBusyStateResponseMessage, + ApiTestConnectionResponse, + ApiFetchModelsResponse, } from '../shared/messages/issues.ts'; import { serializeError } from '../shared/utils/serialize.ts'; import { handleSidepanelToggleEvent } from './sidepanel-button-handler.ts'; @@ -285,6 +292,15 @@ function registerBadgeListeners(): void { void updateActionBadge(); }); + onStorageChange(STORAGE_KEYS.MODEL_SOURCE, (newValue) => { + updateModelSourceCache(newValue); + resetProofreaderServices(); + }); + + onStorageChange(STORAGE_KEYS.API_CONFIG, () => { + resetProofreaderServices(); + }); + badgeListenersRegistered = true; } @@ -293,6 +309,7 @@ void updateActionBadge(); chrome.runtime.onInstalled.addListener(async (details) => { await initializeStorage(); + updateModelSourceCache(await getStorageValue(STORAGE_KEYS.MODEL_SOURCE)); logger.info({ reason: details?.reason }, 'Proofly extension installed and storage initialized'); chrome.contextMenus.create({ @@ -316,6 +333,7 @@ chrome.runtime.onInstalled.addListener(async (details) => { chrome.runtime.onStartup.addListener(async () => { await initializeStorage(); + updateModelSourceCache(await getStorageValue(STORAGE_KEYS.MODEL_SOURCE)); logger.info('Proofly extension started'); registerBadgeListeners(); @@ -390,6 +408,45 @@ chrome.runtime.onMessage.addListener((message: ProoflyMessage, sender, sendRespo return handleSidepanelToggleEvent(sendResponse, sender, message); } + if (message.type === 'proofly:api-test-connection') { + getStorageValue(STORAGE_KEYS.API_CONFIG) + .then((apiConfig) => { + const provider = getApiProvider(apiConfig.type); + return provider.testConnection(apiConfig); + }) + .then((result) => { + sendResponse({ + ok: result.ok, + message: result.message, + } satisfies ApiTestConnectionResponse); + }) + .catch((error) => { + sendResponse({ + ok: false, + message: error instanceof Error ? error.message : 'Unknown error', + } satisfies ApiTestConnectionResponse); + }); + return true; + } + + if (message.type === 'proofly:api-fetch-models') { + getStorageValue(STORAGE_KEYS.API_CONFIG) + .then((apiConfig) => { + const provider = getApiProvider(apiConfig.type); + return provider.fetchModels(apiConfig); + }) + .then((models) => { + sendResponse({ ok: true, models } satisfies ApiFetchModelsResponse); + }) + .catch((error) => { + sendResponse({ + ok: false, + message: error instanceof Error ? error.message : 'Failed to fetch models', + } satisfies ApiFetchModelsResponse); + }); + return true; + } + return false; }); diff --git a/src/background/proofreader-proxy.ts b/src/background/proofreader-proxy.ts index dfb4c8a..7730ab5 100644 --- a/src/background/proofreader-proxy.ts +++ b/src/background/proofreader-proxy.ts @@ -4,18 +4,29 @@ import { createProofreaderAdapter, createProofreadingService, } from '../services/proofreader.ts'; +import { getApiProvider } from '../services/api-proofreader.ts'; import type { ProofreadRequestMessage, ProofreadResponse, ProofreadServiceErrorCode, } from '../shared/messages/issues.ts'; +import { getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import { serializeError } from '../shared/utils/serialize.ts'; +import type { ModelSource } from '../shared/types.ts'; const DEFAULT_FALLBACK_LANGUAGE = 'en'; const proofreaderServices = new Map>(); +let apiProofreaderService: ReturnType | null = null; let activeOperations = 0; +let cachedModelSource: ModelSource = 'local'; + +export function updateModelSourceCache(source: ModelSource): void { + cachedModelSource = source; +} + const getLanguageCacheKey = (language: string): string => language.trim().toLowerCase() || DEFAULT_FALLBACK_LANGUAGE; @@ -40,6 +51,22 @@ const isUnsupportedLanguageError = (error: unknown): boolean => { return message.includes('language options') || message.includes('unsupported language'); }; +async function getOrCreateApiProofreaderService(): Promise< + ReturnType +> { + if (apiProofreaderService) { + return apiProofreaderService; + } + + const { apiConfig } = await getStorageValues([STORAGE_KEYS.API_CONFIG]); + logger.info({ model: apiConfig.selectedModel }, 'Initializing API proofreader service'); + + const provider = getApiProvider(apiConfig.type); + const proofreader = provider.createProofreader(apiConfig); + apiProofreaderService = createProofreadingService(proofreader); + return apiProofreaderService; +} + async function getOrCreateProofreaderServiceForLanguage( language: string, fallbackLanguage: string @@ -75,10 +102,16 @@ export async function handleProofreadRequest( activeOperations += 1; try { - const service = await getOrCreateProofreaderServiceForLanguage( - requestedLanguage, - normalizedFallback - ); + let service: ReturnType; + if (cachedModelSource === 'api') { + service = await getOrCreateApiProofreaderService(); + } else { + service = await getOrCreateProofreaderServiceForLanguage( + requestedLanguage, + normalizedFallback + ); + } + const result = await service.proofread(text); logger.info( { @@ -132,6 +165,8 @@ export function resetProofreaderServices(): void { } proofreaderServices.forEach((service) => service.destroy()); proofreaderServices.clear(); + apiProofreaderService?.destroy(); + apiProofreaderService = null; } export function isProofreaderProxyBusy(): boolean { diff --git a/src/options/api-config-section.ts b/src/options/api-config-section.ts new file mode 100644 index 0000000..2f38b6e --- /dev/null +++ b/src/options/api-config-section.ts @@ -0,0 +1,243 @@ +import { debounce } from '../shared/utils/debounce.ts'; +import { setStorageValue } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; +import { requestHostPermission } from '../services/api-proofreader.ts'; +import { logger } from '../services/logger.ts'; +import type { ModelSource, ApiConfig } from '../shared/types.ts'; +import type { + ApiTestConnectionResponse, + ApiFetchModelsResponse, +} from '../shared/messages/issues.ts'; + +export interface ApiConfigSectionOptions { + initialModelSource: ModelSource; + initialApiConfig: ApiConfig; + onModelSourceChange: (source: ModelSource) => void; +} + +export interface ApiConfigSectionControls { + getCurrentModelSource(): ModelSource; + getCurrentApiConfig(): ApiConfig; + destroy(): void; +} + +export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConfigSectionControls { + let currentModelSource = options.initialModelSource; + let currentApiConfig = structuredClone(options.initialApiConfig); + const cleanups: Array<() => void> = []; + + const saveApiConfig = debounce(async (config: ApiConfig) => { + await setStorageValue(STORAGE_KEYS.API_CONFIG, structuredClone(config)).catch((err) => { + logger.error({ err }, 'Failed to save API config'); + }); + }, 500); + + const apiConfigSection = document.querySelector('#apiConfigSection'); + const localModelStatus = document.querySelector('#localModelStatus'); + const apiUrlInput = document.querySelector('#apiUrl'); + const apiKeyInput = document.querySelector('#apiKey'); + const toggleApiKeyBtn = document.querySelector('#toggleApiKey'); + const testConnectionBtn = document.querySelector('#testConnectionBtn'); + const fetchModelsBtn = document.querySelector('#fetchModelsBtn'); + const connectionStatus = document.querySelector('#connectionStatus'); + const modelSelectField = document.querySelector('#modelSelectField'); + const selectedModelSelect = document.querySelector('#selectedModel'); + + if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; + if (apiKeyInput) apiKeyInput.value = currentApiConfig.apiKey; + + if (currentApiConfig.selectedModel && selectedModelSelect) { + const label = currentApiConfig.selectedModelDisplayName || currentApiConfig.selectedModel; + selectedModelSelect.innerHTML = ``; + } + + // ── Status display with timer cleanup ────────────────────────────────── + + let statusTimer: ReturnType | null = null; + + const showConnectionStatus = (msg: string, type: 'success' | 'error', autoDismissMs = 4000) => { + if (!connectionStatus) return; + if (statusTimer) clearTimeout(statusTimer); + connectionStatus.textContent = msg; + connectionStatus.className = `connection-status visible ${type}`; + statusTimer = setTimeout(() => { + connectionStatus.className = 'connection-status'; + statusTimer = null; + }, autoDismissMs); + }; + + // ── Source toggle ────────────────────────────────────────────────────── + + const sourceRadios = Array.from( + document.querySelectorAll('input[name="modelSource"]') + ); + + const onSourceChange = async (event: Event) => { + const radio = event.target as HTMLInputElement; + if (!radio.checked) return; + currentModelSource = radio.value as ModelSource; + await setStorageValue(STORAGE_KEYS.MODEL_SOURCE, currentModelSource); + apiConfigSection?.toggleAttribute('hidden', currentModelSource !== 'api'); + localModelStatus?.toggleAttribute('hidden', currentModelSource === 'api'); + options.onModelSourceChange(currentModelSource); + }; + + sourceRadios.forEach((radio) => radio.addEventListener('change', onSourceChange)); + cleanups.push(() => + sourceRadios.forEach((radio) => radio.removeEventListener('change', onSourceChange)) + ); + + // ── Form fields ──────────────────────────────────────────────────────── + + const onApiUrlInput = () => { + if (!apiUrlInput) return; + currentApiConfig = { ...currentApiConfig, apiUrl: apiUrlInput.value }; + void saveApiConfig(currentApiConfig); + }; + + const onApiKeyInput = () => { + if (!apiKeyInput) return; + currentApiConfig = { ...currentApiConfig, apiKey: apiKeyInput.value }; + void saveApiConfig(currentApiConfig); + }; + + const onToggleApiKey = () => { + if (!apiKeyInput) return; + const isPassword = apiKeyInput.type === 'password'; + apiKeyInput.type = isPassword ? 'text' : 'password'; + if (toggleApiKeyBtn) toggleApiKeyBtn.textContent = isPassword ? 'Hide' : 'Show'; + }; + + apiUrlInput?.addEventListener('input', onApiUrlInput); + apiKeyInput?.addEventListener('input', onApiKeyInput); + toggleApiKeyBtn?.addEventListener('click', onToggleApiKey); + cleanups.push(() => { + apiUrlInput?.removeEventListener('input', onApiUrlInput); + apiKeyInput?.removeEventListener('input', onApiKeyInput); + toggleApiKeyBtn?.removeEventListener('click', onToggleApiKey); + }); + + // ── Test connection ──────────────────────────────────────────────────── + + const onTestConnection = async () => { + if (!testConnectionBtn) return; + testConnectionBtn.disabled = true; + testConnectionBtn.textContent = 'Testing…'; + connectionStatus?.classList.remove('visible'); + + try { + const granted = await requestHostPermission(currentApiConfig.apiUrl); + if (!granted) { + showConnectionStatus('Host permission denied by user', 'error'); + return; + } + + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: 'proofly:api-test-connection' }, (res) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(res as ApiTestConnectionResponse); + } + }); + }); + showConnectionStatus( + response.ok ? 'Connected' : response.message, + response.ok ? 'success' : 'error' + ); + } catch (err) { + showConnectionStatus(err instanceof Error ? err.message : 'Connection failed', 'error'); + } finally { + testConnectionBtn.disabled = false; + testConnectionBtn.textContent = 'Test connection'; + } + }; + + testConnectionBtn?.addEventListener('click', onTestConnection); + cleanups.push(() => testConnectionBtn?.removeEventListener('click', onTestConnection)); + + // ── Fetch models ─────────────────────────────────────────────────────── + + const onFetchModels = async () => { + if (!fetchModelsBtn) return; + fetchModelsBtn.disabled = true; + fetchModelsBtn.textContent = 'Fetching…'; + + try { + const granted = await requestHostPermission(currentApiConfig.apiUrl); + if (!granted) { + showConnectionStatus('Host permission denied by user', 'error'); + return; + } + + const response = await new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ type: 'proofly:api-fetch-models' }, (res) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(res as ApiFetchModelsResponse); + } + }); + }); + + if (response.ok && response.models && selectedModelSelect && modelSelectField) { + selectedModelSelect.innerHTML = response.models + .map( + (m) => + `` + ) + .join(''); + modelSelectField.removeAttribute('hidden'); + showConnectionStatus(`${response.models.length} models loaded`, 'success'); + + if (!currentApiConfig.selectedModel && response.models[0]) { + const first = response.models[0]; + currentApiConfig = { + ...currentApiConfig, + selectedModel: first.id, + selectedModelDisplayName: first.displayName, + }; + selectedModelSelect.value = first.id; + void saveApiConfig(currentApiConfig); + } + } else if (!response.ok) { + showConnectionStatus(response.message ?? 'Failed to fetch models', 'error'); + } + } catch (err) { + showConnectionStatus(err instanceof Error ? err.message : 'Failed to fetch models', 'error'); + } finally { + fetchModelsBtn.disabled = false; + fetchModelsBtn.textContent = 'Fetch models'; + } + }; + + fetchModelsBtn?.addEventListener('click', onFetchModels); + cleanups.push(() => fetchModelsBtn?.removeEventListener('click', onFetchModels)); + + // ── Model select ─────────────────────────────────────────────────────── + + const onModelChange = () => { + if (!selectedModelSelect) return; + const selectedOption = selectedModelSelect.selectedOptions[0]; + currentApiConfig = { + ...currentApiConfig, + selectedModel: selectedModelSelect.value, + selectedModelDisplayName: selectedOption?.textContent ?? selectedModelSelect.value, + }; + void saveApiConfig(currentApiConfig); + }; + + selectedModelSelect?.addEventListener('change', onModelChange); + cleanups.push(() => selectedModelSelect?.removeEventListener('change', onModelChange)); + + return { + getCurrentModelSource: () => currentModelSource, + getCurrentApiConfig: () => structuredClone(currentApiConfig), + destroy() { + if (statusTimer) clearTimeout(statusTimer); + saveApiConfig.cancel(); + cleanups.forEach((fn) => fn()); + cleanups.length = 0; + }, + }; +} diff --git a/src/options/main.ts b/src/options/main.ts index 873cb69..4f8d975 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -6,6 +6,7 @@ import { debounce } from '../shared/utils/debounce.ts'; import { isModelReady, getStorageValues, + getStorageValue, onStorageChange, setStorageValue, } from '../shared/utils/storage.ts'; @@ -17,6 +18,7 @@ import { createProofreader, createProofreaderAdapter, createProofreadingService, + type IProofreader, } from '../services/proofreader.ts'; import { createProofreadingController, @@ -45,6 +47,7 @@ import { type IssuesUpdateMessage, type IssuesUpdatePayload, } from '../shared/messages/issues.ts'; +import { setupApiConfigSection } from './api-config-section.ts'; const LIVE_TEST_SAMPLE_TEXT = `i love how Proofly help proofread any of my writting at web in a fully privet way, the user-experience is topnotch and immensly helpful.`; @@ -62,12 +65,45 @@ interface LiveTestAreaOptions { isAutoCorrectEnabled: () => boolean; } +function createBackgroundProofreaderAdapter(): IProofreader { + return { + async proofread(text: string) { + const requestId = `options-${Date.now()}-${Math.random().toString(36).slice(2)}`; + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage( + { + type: 'proofly:proofread-request', + payload: { requestId, text, language: 'en', fallbackLanguage: 'en' }, + }, + (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + if (response?.ok) { + resolve(response.result); + } else { + reject(new Error(response?.error?.message ?? 'Proofread request failed')); + } + } + ); + }); + }, + destroy() {}, + }; +} + async function initOptions() { const app = document.querySelector('#app')!; - await ensureProofreaderModelReady(); + const modelSource = await getStorageValue(STORAGE_KEYS.MODEL_SOURCE); + const isApiMode = modelSource === 'api'; + + if (!isApiMode) { + await ensureProofreaderModelReady(); + } - const modelReady = await isModelReady(); + const modelReady = isApiMode ? true : await isModelReady(); if (!modelReady) { app.innerHTML = ` @@ -82,11 +118,44 @@ async function initOptions() {
+
+

AI Source

+

Choose how Proofly accesses the AI model for proofreading.

+
+
+ + +
+
+
`; + const welcomeSourceRadios = Array.from( + app.querySelectorAll('input[name="modelSource"]') + ); + welcomeSourceRadios.forEach((radio) => { + radio.addEventListener('change', async () => { + if (!radio.checked || radio.value !== 'api') return; + await setStorageValue(STORAGE_KEYS.MODEL_SOURCE, 'api'); + location.reload(); + }); + }); + const downloader = app.querySelector('proofly-model-downloader'); downloader?.addEventListener('download-complete', () => { location.reload(); @@ -266,8 +335,67 @@ async function initOptions() {
+

AI Source

+

Choose how Proofly accesses the AI model for proofreading.

+
+
+ + +
+
+
+ +
+

API Configuration

+

Connect Proofly to an external AI API.

+
+
+
+ + +
+
+ + + Base URL for the Anthropic API +
+
+ +
+ + +
+
+
+ + + +
+ +
+
+
+ +

Model Status

-

Review the status of AI models.

+

Review the status of the local AI model.

@@ -701,13 +829,33 @@ async function initOptions() { updateShortcutDisplay(); }); + // ── AI Source + API Configuration (delegated) ────────────────────────── + const apiConfig = await getStorageValue(STORAGE_KEYS.API_CONFIG); + setupApiConfigSection({ + initialModelSource: modelSource, + initialApiConfig: apiConfig, + onModelSourceChange: () => {}, + }); + // Setup live test area proofreading + const liveTestProofreader: IProofreader | null = isApiMode + ? createBackgroundProofreaderAdapter() + : await (async () => { + try { + const proofreader = await createProofreader(); + return createProofreaderAdapter(proofreader); + } catch { + return null; + } + })(); + liveTestControls = await setupLiveTestArea( currentEnabledCorrectionTypes, correctionColorConfig, { isAutoCorrectEnabled: () => autoCorrectEnabled, - } + }, + liveTestProofreader ); // Handle apply issue and apply all messages from sidepanel @@ -754,7 +902,8 @@ async function initOptions() { async function setupLiveTestArea( initialEnabledTypes: CorrectionTypeKey[], initialColorConfig: CorrectionColorConfig, - options: LiveTestAreaOptions + options: LiveTestAreaOptions, + proofreaderAdapter: IProofreader | null = null ): Promise { const editor = document.getElementById('liveTestEditor'); if (!editor) return null; @@ -778,13 +927,17 @@ async function setupLiveTestArea( let proofreaderService: ReturnType | null = null; - try { - const proofreader = await createProofreader(); - const adapter = createProofreaderAdapter(proofreader); - proofreaderService = createProofreadingService(adapter); - } catch (error) { - logger.error({ error }, 'Failed to initialize proofreader for live test area'); - return null; + if (proofreaderAdapter) { + proofreaderService = createProofreadingService(proofreaderAdapter); + } else { + try { + const proofreader = await createProofreader(); + const adapter = createProofreaderAdapter(proofreader); + proofreaderService = createProofreadingService(adapter); + } catch (error) { + logger.error({ error }, 'Failed to initialize proofreader for live test area'); + return null; + } } const reportProofreaderBusy = (busy: boolean) => { diff --git a/src/options/style.css b/src/options/style.css index fbbc246..0d7518f 100644 --- a/src/options/style.css +++ b/src/options/style.css @@ -28,6 +28,12 @@ --option-color-text-tertiary: #4b5563; --option-color-focus-border: #4f46e5; --option-color-focus-ring: rgba(79, 70, 229, 0.1); + --option-color-success-bg: #ecfdf5; + --option-color-success-text: #065f46; + --option-color-success-border: #a7f3d0; + --option-color-error-bg: #fef2f2; + --option-color-error-text: #991b1b; + --option-color-error-border: #fecaca; } body { @@ -521,3 +527,203 @@ prfly-checkbox.option-card--single::part(control) { font-size: 14px; line-height: 1.5; } + +/* ─── Model Source Selector ─────────────────────────────────────────────── */ + +.source-selector { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.source-option { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.5rem; + background: var(--option-color-card-bg); + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.source-option:hover, +.source-option:has(input:checked) { + border-color: var(--option-color-button-border); + box-shadow: 0 0 0 3px var(--option-color-card-ring); +} + +.source-option input[type='radio'] { + margin-top: 2px; + accent-color: var(--option-color-button-bg); + flex-shrink: 0; +} + +.source-option-content { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.source-option-content strong { + font-size: 0.9rem; + color: var(--option-color-text-primary); +} + +.source-option-content p { + margin: 0; + font-size: 0.8rem; + color: var(--option-color-text-secondary); +} + +/* ─── API Configuration Form ────────────────────────────────────────────── */ + +.api-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.form-field label { + font-size: 0.85rem; + font-weight: 500; + color: var(--option-color-text-primary); +} + +.form-field .field-hint { + font-size: 0.75rem; + color: var(--option-color-text-secondary); +} + +.form-field input[type='text'], +.form-field input[type='url'], +.form-field input[type='password'], +.form-field select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + font-size: 0.875rem; + color: var(--option-color-text-primary); + background: var(--option-color-card-bg); + box-sizing: border-box; + transition: + border-color 0.15s, + box-shadow 0.15s; +} + +.form-field input:focus, +.form-field select:focus { + outline: none; + border-color: var(--option-color-focus-border); + box-shadow: 0 0 0 3px var(--option-color-focus-ring); +} + +.api-key-wrapper { + display: flex; + gap: 0.5rem; +} + +.api-key-wrapper input { + flex: 1; + min-width: 0; +} + +.btn-icon { + padding: 0.5rem 0.75rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + background: var(--option-color-surface-muted); + font-size: 0.8rem; + color: var(--option-color-text-tertiary); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s; +} + +.btn-icon:hover { + background: var(--option-color-neutral-bg); +} + +.api-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.btn-primary { + padding: 0.5rem 1rem; + border: 1px solid var(--option-color-button-border); + border-radius: 0.375rem; + background: var(--option-color-button-bg); + color: var(--option-color-button-text); + font-size: 0.875rem; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; +} + +.btn-primary:hover:not(:disabled) { + background: var(--option-color-button-hover-bg); + border-color: var(--option-color-button-hover-border); +} + +.btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + padding: 0.5rem 1rem; + border: 1px solid var(--option-color-card-border); + border-radius: 0.375rem; + background: var(--option-color-surface-muted); + color: var(--option-color-text-muted); + font-size: 0.875rem; + cursor: pointer; + transition: background 0.15s; +} + +.btn-secondary:hover:not(:disabled) { + background: var(--option-color-neutral-bg); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.connection-status { + font-size: 0.8rem; + padding: 0.3rem 0.6rem; + border-radius: 0.375rem; + display: none; +} + +.connection-status.visible { + display: inline-block; +} + +.connection-status.success { + background: var(--option-color-success-bg); + color: var(--option-color-success-text); + border: 1px solid var(--option-color-success-border); +} + +.connection-status.error { + background: var(--option-color-error-bg); + color: var(--option-color-error-text); + border: 1px solid var(--option-color-error-border); +} diff --git a/src/services/api-proofreader.ts b/src/services/api-proofreader.ts new file mode 100644 index 0000000..d906f87 --- /dev/null +++ b/src/services/api-proofreader.ts @@ -0,0 +1,316 @@ +import type { IProofreader } from './proofreader.ts'; +import type { + ProofreadResult, + ProofreadCorrection, + CorrectionType, + ApiConfig, + ApiType, +} from '../shared/types.ts'; +import { logger } from './logger.ts'; + +// ─── Shared utilities ────────────────────────────────────────────────────── + +const DEFAULT_TIMEOUT_MS = 30_000; + +function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +async function ensureHostPermission(apiUrl: string): Promise { + const origin = new URL(apiUrl).origin + '/*'; + const granted = await chrome.permissions.contains({ origins: [origin] }); + if (!granted) { + throw new Error( + `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` + ); + } +} + +// ─── Provider interface ──────────────────────────────────────────────────── + +export interface ApiTestResult { + ok: boolean; + message: string; +} + +export interface ApiModel { + id: string; + displayName: string; +} + +export interface ApiProvider { + testConnection(config: ApiConfig): Promise; + fetchModels(config: ApiConfig): Promise; + createProofreader(config: ApiConfig): IProofreader; +} + +// ─── Claude provider ─────────────────────────────────────────────────────── + +const ANTHROPIC_API_VERSION = '2023-06-01'; + +const PROOFREAD_TOOL = { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + input_schema: { + type: 'object' as const, + properties: { + corrections: { + type: 'array', + items: { + type: 'object', + properties: { + originalText: { + type: 'string', + description: 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'string', + description: 'The corrected replacement text', + }, + type: { + type: 'string', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'string', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, +}; + +interface ClaudeRawCorrection { + originalText: string; + correctedText: string; + type?: string; + explanation?: string; +} + +function resolvePositions( + text: string, + claudeCorrections: ClaudeRawCorrection[] +): ProofreadCorrection[] { + const corrections: ProofreadCorrection[] = []; + const usedRanges: Array<[number, number]> = []; + + for (const raw of claudeCorrections) { + const { originalText, correctedText, type, explanation } = raw; + if (!originalText || originalText === correctedText) continue; + + let searchFrom = 0; + let idx = -1; + while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { + const end = idx + originalText.length; + const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); + if (!overlaps) break; + searchFrom = idx + 1; + } + if (idx === -1) continue; + + usedRanges.push([idx, idx + originalText.length]); + corrections.push({ + startIndex: idx, + endIndex: idx + originalText.length, + correction: correctedText, + type: type as CorrectionType | undefined, + explanation, + }); + } + + corrections.sort((a, b) => a.startIndex - b.startIndex); + const filtered: ProofreadCorrection[] = []; + let lastEnd = -1; + for (const c of corrections) { + if (c.startIndex >= lastEnd) { + filtered.push(c); + lastEnd = c.endIndex; + } + } + + return filtered; +} + +function buildClaudeHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + 'anthropic-version': ANTHROPIC_API_VERSION, + }; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + return headers; +} + +function parseApiError(response: Response, errorData: unknown): string { + return ( + (errorData as { error?: { message?: string } })?.error?.message ?? + `HTTP ${response.status}: ${response.statusText}` + ); +} + +async function testClaudeConnection(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + try { + const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { + headers: buildClaudeHeaders(apiKey), + }); + + if (response.ok) { + return { ok: true, message: 'Connection successful' }; + } + + const errorData = await response.json().catch(() => ({})); + return { ok: false, message: parseApiError(response, errorData) }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return { ok: false, message: 'Connection timed out' }; + } + const msg = error instanceof Error ? error.message : 'Connection failed'; + return { ok: false, message: msg }; + } +} + +async function fetchClaudeModels(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { + headers: buildClaudeHeaders(apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseApiError(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ + id: string; + display_name?: string; + }>; + return models.map((m) => ({ + id: m.id, + displayName: m.display_name ?? m.id, + })); +} + +function createClaudeApiProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiUrl, apiKey, selectedModel } = config; + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(apiUrl); + + const response = await fetchWithTimeout(`${apiUrl}/v1/messages`, { + method: 'POST', + headers: buildClaudeHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + max_tokens: 2048, + system: + 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.', + messages: [ + { + role: 'user', + content: `Proofread the following text and report all errors:\n\n${text}`, + }, + ], + tools: [PROOFREAD_TOOL], + tool_choice: { type: 'any' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseApiError(response, errorData)); + } + + const data = await response.json(); + + const toolUse = ( + data.content as Array<{ + type: string; + name?: string; + input?: unknown; + }> + )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); + + if (!toolUse) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections: ClaudeRawCorrection[] = + (toolUse.input as { corrections?: ClaudeRawCorrection[] })?.corrections ?? []; + + const corrections = resolvePositions(text, rawCorrections); + + let correctedInput = text; + const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); + for (const correction of sorted) { + correctedInput = + correctedInput.slice(0, correction.startIndex) + + correction.correction + + correctedInput.slice(correction.endIndex); + } + + logger.info({ corrections: corrections.length }, 'Claude API proofreading completed'); + + return { correctedInput, corrections }; + }, + + destroy() {}, + }; +} + +const claudeProvider: ApiProvider = { + testConnection: testClaudeConnection, + fetchModels: fetchClaudeModels, + createProofreader: createClaudeApiProofreader, +}; + +// ─── Provider registry ───────────────────────────────────────────────────── + +const providers = new Map([['claude', claudeProvider]]); + +export function getApiProvider(type: ApiType): ApiProvider { + const provider = providers.get(type); + if (!provider) { + throw new Error(`Unsupported API provider: ${type}`); + } + return provider; +} + +export async function requestHostPermission(apiUrl: string): Promise { + try { + const origin = new URL(apiUrl).origin + '/*'; + const has = await chrome.permissions.contains({ origins: [origin] }); + if (has) return true; + return chrome.permissions.request({ origins: [origin] }); + } catch { + return false; + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 64cdbf1..acb45cc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,4 +1,4 @@ -import type { UnderlineStyle } from './types.ts'; +import type { UnderlineStyle, ModelSource, ApiConfig } from './types.ts'; import { ALL_CORRECTION_TYPES, getDefaultCorrectionColorConfig } from './utils/correction-types.ts'; /** @@ -14,6 +14,8 @@ export const STORAGE_KEYS = { CORRECTION_COLORS: 'correctionColors', PROOFREAD_SHORTCUT: 'proofreadShortcut', AUTOFIX_ON_DOUBLE_CLICK: 'autofixOnDoubleClick', + MODEL_SOURCE: 'modelSource', + API_CONFIG: 'apiConfig', } as const; /** @@ -29,4 +31,12 @@ export const STORAGE_DEFAULTS = { [STORAGE_KEYS.CORRECTION_COLORS]: getDefaultCorrectionColorConfig(), [STORAGE_KEYS.PROOFREAD_SHORTCUT]: 'Mod+Shift+P', [STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK]: false, + [STORAGE_KEYS.MODEL_SOURCE]: 'local' as ModelSource, + [STORAGE_KEYS.API_CONFIG]: { + type: 'claude', + apiUrl: 'https://api.anthropic.com', + apiKey: '', + selectedModel: '', + selectedModelDisplayName: '', + } as ApiConfig, } as const; diff --git a/src/shared/messages/issues.ts b/src/shared/messages/issues.ts index 1705a17..fff8636 100644 --- a/src/shared/messages/issues.ts +++ b/src/shared/messages/issues.ts @@ -167,6 +167,25 @@ export interface DevOpenSidepanelMessage { }; } +export interface ApiTestConnectionMessage { + type: 'proofly:api-test-connection'; +} + +export interface ApiTestConnectionResponse { + ok: boolean; + message: string; +} + +export interface ApiFetchModelsMessage { + type: 'proofly:api-fetch-models'; +} + +export interface ApiFetchModelsResponse { + ok: boolean; + models?: Array<{ id: string; displayName: string }>; + message?: string; +} + export type ProoflyMessage = | IssuesUpdateMessage | ApplyIssueMessage @@ -180,7 +199,9 @@ export type ProoflyMessage = | ProofreadRequestMessage | ProofreaderBusyStateRequestMessage | ProofreaderBusyStateResponseMessage - | DevOpenSidepanelMessage; + | DevOpenSidepanelMessage + | ApiTestConnectionMessage + | ApiFetchModelsMessage; export function toSidepanelIssue( elementId: string, diff --git a/src/shared/types.ts b/src/shared/types.ts index 503a89d..5af1ed2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,17 @@ export type UnderlineStyle = 'solid' | 'wavy' | 'dotted'; +export type ModelSource = 'local' | 'api'; + +export type ApiType = 'claude' | 'openai-compatible'; + +export interface ApiConfig { + type: ApiType; + apiUrl: string; + apiKey: string; + selectedModel: string; + selectedModelDisplayName: string; +} + export type CorrectionType = | 'spelling' | 'grammar' diff --git a/src/shared/utils/storage.ts b/src/shared/utils/storage.ts index e03fa0d..b4e55b5 100644 --- a/src/shared/utils/storage.ts +++ b/src/shared/utils/storage.ts @@ -7,7 +7,7 @@ */ import { STORAGE_KEYS, STORAGE_DEFAULTS } from '../constants.ts'; -import type { UnderlineStyle } from '../types.ts'; +import type { UnderlineStyle, ModelSource, ApiConfig } from '../types.ts'; import type { CorrectionColorConfig, CorrectionTypeKey } from './correction-types.ts'; export interface StorageData { @@ -20,6 +20,8 @@ export interface StorageData { [STORAGE_KEYS.CORRECTION_COLORS]: CorrectionColorConfig; [STORAGE_KEYS.PROOFREAD_SHORTCUT]: string; [STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK]: boolean; + [STORAGE_KEYS.MODEL_SOURCE]: ModelSource; + [STORAGE_KEYS.API_CONFIG]: ApiConfig; } /** @@ -32,6 +34,8 @@ const SYNC_KEYS = [ STORAGE_KEYS.CORRECTION_COLORS, STORAGE_KEYS.PROOFREAD_SHORTCUT, STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK, + STORAGE_KEYS.MODEL_SOURCE, + // NOTE: API_CONFIG is intentionally in local storage (contains API key) ] as const; /** From e48960871da76a994b59b7989c87cf85a6e9c0e2 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 19:23:59 -0500 Subject: [PATCH 2/9] feat: expand API integration for additional proofreading services * Added support for Gemini and OpenAI-compatible APIs in the configuration options. * Enhanced the API configuration UI to include new options for selecting API types. * Implemented functionality to handle API-specific connection testing and model fetching. * Updated the proofreading logic to accommodate different API response formats and correction handling. * Refactored utility functions for improved code organization and reusability. --- src/options/api-config-section.ts | 57 +++- src/options/main.ts | 12 +- src/services/api-proofreader.ts | 310 +------------------ src/services/providers/api-provider-utils.ts | 158 ++++++++++ src/services/providers/claude.ts | 151 +++++++++ src/services/providers/gemini.ts | 168 ++++++++++ src/services/providers/openai-compatible.ts | 159 ++++++++++ src/shared/types.ts | 2 +- 8 files changed, 713 insertions(+), 304 deletions(-) create mode 100644 src/services/providers/api-provider-utils.ts create mode 100644 src/services/providers/claude.ts create mode 100644 src/services/providers/gemini.ts create mode 100644 src/services/providers/openai-compatible.ts diff --git a/src/options/api-config-section.ts b/src/options/api-config-section.ts index 2f38b6e..4949f28 100644 --- a/src/options/api-config-section.ts +++ b/src/options/api-config-section.ts @@ -3,12 +3,30 @@ import { setStorageValue } from '../shared/utils/storage.ts'; import { STORAGE_KEYS } from '../shared/constants.ts'; import { requestHostPermission } from '../services/api-proofreader.ts'; import { logger } from '../services/logger.ts'; -import type { ModelSource, ApiConfig } from '../shared/types.ts'; +import type { ModelSource, ApiConfig, ApiType } from '../shared/types.ts'; import type { ApiTestConnectionResponse, ApiFetchModelsResponse, } from '../shared/messages/issues.ts'; +const API_TYPE_DEFAULTS: Record = { + claude: { + url: 'https://api.anthropic.com', + placeholder: 'sk-ant-...', + keyHint: 'Anthropic API key', + }, + gemini: { + url: 'https://generativelanguage.googleapis.com', + placeholder: 'AIza...', + keyHint: 'Google AI API key', + }, + 'openai-compatible': { + url: 'https://api.openai.com', + placeholder: 'sk-...', + keyHint: 'API key', + }, +}; + export interface ApiConfigSectionOptions { initialModelSource: ModelSource; initialApiConfig: ApiConfig; @@ -34,7 +52,9 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf const apiConfigSection = document.querySelector('#apiConfigSection'); const localModelStatus = document.querySelector('#localModelStatus'); + const apiTypeSelect = document.querySelector('#apiType'); const apiUrlInput = document.querySelector('#apiUrl'); + const apiUrlHint = document.querySelector('#apiUrlHint'); const apiKeyInput = document.querySelector('#apiKey'); const toggleApiKeyBtn = document.querySelector('#toggleApiKey'); const testConnectionBtn = document.querySelector('#testConnectionBtn'); @@ -43,12 +63,24 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf const modelSelectField = document.querySelector('#modelSelectField'); const selectedModelSelect = document.querySelector('#selectedModel'); + const applyTypeDefaults = (type: ApiType) => { + const defaults = API_TYPE_DEFAULTS[type]; + if (!defaults) return; + if (apiUrlInput) apiUrlInput.placeholder = defaults.url; + if (apiKeyInput) apiKeyInput.placeholder = defaults.placeholder; + if (apiUrlHint) + apiUrlHint.textContent = `Base URL for the ${type === 'claude' ? 'Anthropic' : type === 'gemini' ? 'Google AI' : ''} API`; + }; + + if (apiTypeSelect) apiTypeSelect.value = currentApiConfig.type; if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; if (apiKeyInput) apiKeyInput.value = currentApiConfig.apiKey; + applyTypeDefaults(currentApiConfig.type); if (currentApiConfig.selectedModel && selectedModelSelect) { const label = currentApiConfig.selectedModelDisplayName || currentApiConfig.selectedModel; selectedModelSelect.innerHTML = ``; + modelSelectField?.removeAttribute('hidden'); } // ── Status display with timer cleanup ────────────────────────────────── @@ -87,6 +119,29 @@ export function setupApiConfigSection(options: ApiConfigSectionOptions): ApiConf sourceRadios.forEach((radio) => radio.removeEventListener('change', onSourceChange)) ); + // ── API type change ────────────────────────────────────────────────── + + const onApiTypeChange = () => { + if (!apiTypeSelect) return; + const newType = apiTypeSelect.value as ApiType; + const defaults = API_TYPE_DEFAULTS[newType]; + currentApiConfig = { + ...currentApiConfig, + type: newType, + apiUrl: defaults?.url ?? currentApiConfig.apiUrl, + selectedModel: '', + selectedModelDisplayName: '', + }; + if (apiUrlInput) apiUrlInput.value = currentApiConfig.apiUrl; + applyTypeDefaults(newType); + modelSelectField?.setAttribute('hidden', ''); + if (selectedModelSelect) selectedModelSelect.innerHTML = ''; + void saveApiConfig(currentApiConfig); + }; + + apiTypeSelect?.addEventListener('change', onApiTypeChange); + cleanups.push(() => apiTypeSelect?.removeEventListener('change', onApiTypeChange)); + // ── Form fields ──────────────────────────────────────────────────────── const onApiUrlInput = () => { diff --git a/src/options/main.ts b/src/options/main.ts index 4f8d975..64387da 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -134,7 +134,7 @@ async function initOptions() {
API -

Uses an external AI API (e.g. Claude by Anthropic).

+

Uses an external AI API (e.g. Claude, Gemini).

@@ -350,7 +350,7 @@ async function initOptions() {
API -

Uses an external AI API (e.g. Claude by Anthropic).

+

Uses an external AI API (e.g. Claude, Gemini).

@@ -365,16 +365,18 @@ async function initOptions() {
- Base URL for the Anthropic API + Base URL for the API
- +
diff --git a/src/services/api-proofreader.ts b/src/services/api-proofreader.ts index d906f87..4bdedab 100644 --- a/src/services/api-proofreader.ts +++ b/src/services/api-proofreader.ts @@ -1,300 +1,16 @@ -import type { IProofreader } from './proofreader.ts'; -import type { - ProofreadResult, - ProofreadCorrection, - CorrectionType, - ApiConfig, - ApiType, -} from '../shared/types.ts'; -import { logger } from './logger.ts'; - -// ─── Shared utilities ────────────────────────────────────────────────────── - -const DEFAULT_TIMEOUT_MS = 30_000; - -function fetchWithTimeout( - url: string, - init: RequestInit, - timeoutMs = DEFAULT_TIMEOUT_MS -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); -} - -async function ensureHostPermission(apiUrl: string): Promise { - const origin = new URL(apiUrl).origin + '/*'; - const granted = await chrome.permissions.contains({ origins: [origin] }); - if (!granted) { - throw new Error( - `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` - ); - } -} - -// ─── Provider interface ──────────────────────────────────────────────────── - -export interface ApiTestResult { - ok: boolean; - message: string; -} - -export interface ApiModel { - id: string; - displayName: string; -} - -export interface ApiProvider { - testConnection(config: ApiConfig): Promise; - fetchModels(config: ApiConfig): Promise; - createProofreader(config: ApiConfig): IProofreader; -} - -// ─── Claude provider ─────────────────────────────────────────────────────── - -const ANTHROPIC_API_VERSION = '2023-06-01'; - -const PROOFREAD_TOOL = { - name: 'report_corrections', - description: - 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', - input_schema: { - type: 'object' as const, - properties: { - corrections: { - type: 'array', - items: { - type: 'object', - properties: { - originalText: { - type: 'string', - description: 'The exact original substring from the input that contains the error', - }, - correctedText: { - type: 'string', - description: 'The corrected replacement text', - }, - type: { - type: 'string', - enum: [ - 'spelling', - 'grammar', - 'punctuation', - 'capitalization', - 'preposition', - 'missing-words', - ], - description: 'The error category', - }, - explanation: { - type: 'string', - description: 'Brief explanation of the correction', - }, - }, - required: ['originalText', 'correctedText'], - }, - }, - }, - required: ['corrections'], - }, -}; - -interface ClaudeRawCorrection { - originalText: string; - correctedText: string; - type?: string; - explanation?: string; -} - -function resolvePositions( - text: string, - claudeCorrections: ClaudeRawCorrection[] -): ProofreadCorrection[] { - const corrections: ProofreadCorrection[] = []; - const usedRanges: Array<[number, number]> = []; - - for (const raw of claudeCorrections) { - const { originalText, correctedText, type, explanation } = raw; - if (!originalText || originalText === correctedText) continue; - - let searchFrom = 0; - let idx = -1; - while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { - const end = idx + originalText.length; - const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); - if (!overlaps) break; - searchFrom = idx + 1; - } - if (idx === -1) continue; - - usedRanges.push([idx, idx + originalText.length]); - corrections.push({ - startIndex: idx, - endIndex: idx + originalText.length, - correction: correctedText, - type: type as CorrectionType | undefined, - explanation, - }); - } - - corrections.sort((a, b) => a.startIndex - b.startIndex); - const filtered: ProofreadCorrection[] = []; - let lastEnd = -1; - for (const c of corrections) { - if (c.startIndex >= lastEnd) { - filtered.push(c); - lastEnd = c.endIndex; - } - } - - return filtered; -} - -function buildClaudeHeaders(apiKey: string): Record { - const headers: Record = { - 'content-type': 'application/json', - 'anthropic-version': ANTHROPIC_API_VERSION, - }; - if (apiKey) { - headers['x-api-key'] = apiKey; - } - return headers; -} - -function parseApiError(response: Response, errorData: unknown): string { - return ( - (errorData as { error?: { message?: string } })?.error?.message ?? - `HTTP ${response.status}: ${response.statusText}` - ); -} - -async function testClaudeConnection(config: ApiConfig): Promise { - const { apiUrl, apiKey } = config; - - try { - const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { - headers: buildClaudeHeaders(apiKey), - }); - - if (response.ok) { - return { ok: true, message: 'Connection successful' }; - } - - const errorData = await response.json().catch(() => ({})); - return { ok: false, message: parseApiError(response, errorData) }; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - return { ok: false, message: 'Connection timed out' }; - } - const msg = error instanceof Error ? error.message : 'Connection failed'; - return { ok: false, message: msg }; - } -} - -async function fetchClaudeModels(config: ApiConfig): Promise { - const { apiUrl, apiKey } = config; - - const response = await fetchWithTimeout(`${apiUrl}/v1/models`, { - headers: buildClaudeHeaders(apiKey), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(parseApiError(response, errorData)); - } - - const data = await response.json(); - const models = (data.data ?? []) as Array<{ - id: string; - display_name?: string; - }>; - return models.map((m) => ({ - id: m.id, - displayName: m.display_name ?? m.id, - })); -} - -function createClaudeApiProofreader(config: ApiConfig): IProofreader { - return { - async proofread(text: string): Promise { - const { apiUrl, apiKey, selectedModel } = config; - - if (!selectedModel) { - throw new Error('No AI model selected. Please choose a model in settings.'); - } - - await ensureHostPermission(apiUrl); - - const response = await fetchWithTimeout(`${apiUrl}/v1/messages`, { - method: 'POST', - headers: buildClaudeHeaders(apiKey), - body: JSON.stringify({ - model: selectedModel, - max_tokens: 2048, - system: - 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.', - messages: [ - { - role: 'user', - content: `Proofread the following text and report all errors:\n\n${text}`, - }, - ], - tools: [PROOFREAD_TOOL], - tool_choice: { type: 'any' }, - }), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(parseApiError(response, errorData)); - } - - const data = await response.json(); - - const toolUse = ( - data.content as Array<{ - type: string; - name?: string; - input?: unknown; - }> - )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); - - if (!toolUse) { - return { correctedInput: text, corrections: [] }; - } - - const rawCorrections: ClaudeRawCorrection[] = - (toolUse.input as { corrections?: ClaudeRawCorrection[] })?.corrections ?? []; - - const corrections = resolvePositions(text, rawCorrections); - - let correctedInput = text; - const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); - for (const correction of sorted) { - correctedInput = - correctedInput.slice(0, correction.startIndex) + - correction.correction + - correctedInput.slice(correction.endIndex); - } - - logger.info({ corrections: corrections.length }, 'Claude API proofreading completed'); - - return { correctedInput, corrections }; - }, - - destroy() {}, - }; -} - -const claudeProvider: ApiProvider = { - testConnection: testClaudeConnection, - fetchModels: fetchClaudeModels, - createProofreader: createClaudeApiProofreader, -}; - -// ─── Provider registry ───────────────────────────────────────────────────── - -const providers = new Map([['claude', claudeProvider]]); +import type { ApiType } from '../shared/types.ts'; +import type { ApiProvider } from './providers/api-provider-utils.ts'; +import { claudeProvider } from './providers/claude.ts'; +import { geminiProvider } from './providers/gemini.ts'; +import { openaiCompatibleProvider } from './providers/openai-compatible.ts'; + +export type { ApiTestResult, ApiModel, ApiProvider } from './providers/api-provider-utils.ts'; + +const providers = new Map([ + ['claude', claudeProvider], + ['gemini', geminiProvider], + ['openai-compatible', openaiCompatibleProvider], +]); export function getApiProvider(type: ApiType): ApiProvider { const provider = providers.get(type); diff --git a/src/services/providers/api-provider-utils.ts b/src/services/providers/api-provider-utils.ts new file mode 100644 index 0000000..f55ff1d --- /dev/null +++ b/src/services/providers/api-provider-utils.ts @@ -0,0 +1,158 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { + ProofreadCorrection, + ProofreadResult, + CorrectionType, + ApiConfig, +} from '../../shared/types.ts'; +import { logger } from '../logger.ts'; + +export interface ApiTestResult { + ok: boolean; + message: string; +} + +export interface ApiModel { + id: string; + displayName: string; +} + +export interface ApiProvider { + testConnection(config: ApiConfig): Promise; + fetchModels(config: ApiConfig): Promise; + createProofreader(config: ApiConfig): IProofreader; +} + +export const DEFAULT_TIMEOUT_MS = 30_000; + +export function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +export const SYSTEM_PROMPT = + 'You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Use the provided tool to report corrections. Only report real errors — not style preferences.'; + +export function buildUserPrompt(text: string): string { + return `Proofread the following text and report all errors:\n\n${text}`; +} + +export interface RawCorrection { + originalText: string; + correctedText: string; + type?: string; + explanation?: string; +} + +export function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); +} + +export async function ensureHostPermission(apiUrl: string): Promise { + const origin = new URL(apiUrl).origin + '/*'; + const granted = await chrome.permissions.contains({ origins: [origin] }); + if (!granted) { + throw new Error( + `Permission not granted for ${new URL(apiUrl).origin}. Please test the connection in Settings first.` + ); + } +} + +export function parseErrorBody(response: Response, errorData: unknown): string { + return ( + (errorData as { error?: { message?: string } })?.error?.message ?? + `HTTP ${response.status}: ${response.statusText}` + ); +} + +export function resolvePositions( + text: string, + rawCorrections: RawCorrection[] +): ProofreadCorrection[] { + const corrections: ProofreadCorrection[] = []; + const usedRanges: Array<[number, number]> = []; + + for (const raw of rawCorrections) { + const { originalText, correctedText, type, explanation } = raw; + if (!originalText || originalText === correctedText) continue; + + let searchFrom = 0; + let idx = -1; + while ((idx = text.indexOf(originalText, searchFrom)) !== -1) { + const end = idx + originalText.length; + const overlaps = usedRanges.some(([s, e]) => idx < e && end > s); + if (!overlaps) break; + searchFrom = idx + 1; + } + if (idx === -1) continue; + + usedRanges.push([idx, idx + originalText.length]); + corrections.push({ + startIndex: idx, + endIndex: idx + originalText.length, + correction: correctedText, + type: type as CorrectionType | undefined, + explanation, + }); + } + + corrections.sort((a, b) => a.startIndex - b.startIndex); + const filtered: ProofreadCorrection[] = []; + let lastEnd = -1; + for (const c of corrections) { + if (c.startIndex >= lastEnd) { + filtered.push(c); + lastEnd = c.endIndex; + } + } + + return filtered; +} + +export function buildProofreadResult( + text: string, + rawCorrections: RawCorrection[], + providerName: string +): ProofreadResult { + const corrections = resolvePositions(text, rawCorrections); + + let correctedInput = text; + const sorted = [...corrections].sort((a, b) => b.startIndex - a.startIndex); + for (const correction of sorted) { + correctedInput = + correctedInput.slice(0, correction.startIndex) + + correction.correction + + correctedInput.slice(correction.endIndex); + } + + logger.info({ corrections: corrections.length }, `${providerName} API proofreading completed`); + + return { correctedInput, corrections }; +} + +export async function testConnectionWith( + fetchFn: () => Promise, + parseError: (res: Response, data: unknown) => string +): Promise<{ ok: boolean; message: string }> { + try { + const response = await fetchFn(); + + if (response.ok) { + return { ok: true, message: 'Connection successful' }; + } + + const errorData = await response.json().catch(() => ({})); + return { ok: false, message: parseError(response, errorData) }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return { ok: false, message: 'Connection timed out' }; + } + const msg = error instanceof Error ? error.message : 'Connection failed'; + return { ok: false, message: msg }; + } +} diff --git a/src/services/providers/claude.ts b/src/services/providers/claude.ts new file mode 100644 index 0000000..364091a --- /dev/null +++ b/src/services/providers/claude.ts @@ -0,0 +1,151 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, + SYSTEM_PROMPT, + buildUserPrompt, +} from './api-provider-utils.ts'; + +const ANTHROPIC_API_VERSION = '2023-06-01'; + +const PROOFREAD_TOOL = { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + input_schema: { + type: 'object' as const, + properties: { + corrections: { + type: 'array', + items: { + type: 'object', + properties: { + originalText: { + type: 'string', + description: 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'string', + description: 'The corrected replacement text', + }, + type: { + type: 'string', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'string', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, +}; + +function buildHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + 'anthropic-version': ANTHROPIC_API_VERSION, + }; + if (apiKey) { + headers['x-api-key'] = apiKey; + } + return headers; +} + +export const claudeProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const base = normalizeBaseUrl(config.apiUrl); + return testConnectionWith( + () => fetchWithTimeout(`${base}/v1/models`, { headers: buildHeaders(config.apiKey) }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const base = normalizeBaseUrl(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/models`, { + headers: buildHeaders(config.apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ id: string; display_name?: string }>; + return models.map((m) => ({ + id: m.id, + displayName: m.display_name ?? m.id, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiKey, selectedModel } = config; + const base = normalizeBaseUrl(config.apiUrl); + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/messages`, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + max_tokens: 2048, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: buildUserPrompt(text) }], + tools: [PROOFREAD_TOOL], + tool_choice: { type: 'any' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + + const toolUse = ( + data.content as Array<{ type: string; name?: string; input?: unknown }> + )?.find((block) => block.type === 'tool_use' && block.name === 'report_corrections'); + + if (!toolUse) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections: RawCorrection[] = + (toolUse.input as { corrections?: RawCorrection[] })?.corrections ?? []; + + return buildProofreadResult(text, rawCorrections, 'Claude'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/services/providers/gemini.ts b/src/services/providers/gemini.ts new file mode 100644 index 0000000..b596220 --- /dev/null +++ b/src/services/providers/gemini.ts @@ -0,0 +1,168 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, + SYSTEM_PROMPT, + buildUserPrompt, +} from './api-provider-utils.ts'; + +interface GeminiFunctionCall { + name: string; + args: Record; +} + +interface GeminiPart { + functionCall?: GeminiFunctionCall; +} + +interface GeminiResponse { + candidates?: Array<{ content?: { parts?: GeminiPart[] } }>; +} + +const GEMINI_PROOFREAD_TOOL = { + function_declarations: [ + { + name: 'report_corrections', + description: + 'Report all proofreading corrections found in the text. Each correction must reference the exact original text from the input.', + parameters: { + type: 'OBJECT', + properties: { + corrections: { + type: 'ARRAY', + items: { + type: 'OBJECT', + properties: { + originalText: { + type: 'STRING', + description: + 'The exact original substring from the input that contains the error', + }, + correctedText: { + type: 'STRING', + description: 'The corrected replacement text', + }, + type: { + type: 'STRING', + enum: [ + 'spelling', + 'grammar', + 'punctuation', + 'capitalization', + 'preposition', + 'missing-words', + ], + description: 'The error category', + }, + explanation: { + type: 'STRING', + description: 'Brief explanation of the correction', + }, + }, + required: ['originalText', 'correctedText'], + }, + }, + }, + required: ['corrections'], + }, + }, + ], +}; + +function buildUrl(apiUrl: string, path: string, apiKey: string): string { + const url = new URL(path, normalizeBaseUrl(apiUrl)); + if (apiKey) url.searchParams.set('key', apiKey); + return url.toString(); +} + +export const geminiProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const { apiUrl, apiKey } = config; + return testConnectionWith( + () => + fetchWithTimeout(buildUrl(apiUrl, '/v1beta/models', apiKey), { + headers: { 'content-type': 'application/json' }, + }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const { apiUrl, apiKey } = config; + + const url = buildUrl(apiUrl, '/v1beta/models?pageSize=1000', apiKey); + const response = await fetchWithTimeout(url, { + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.models ?? []) as Array<{ + name: string; + displayName?: string; + supportedGenerationMethods?: string[]; + }>; + return models + .filter((m) => m.supportedGenerationMethods?.includes('generateContent')) + .map((m) => ({ + id: m.name.replace(/^models\//, ''), + displayName: m.displayName ?? m.name, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiUrl, apiKey, selectedModel } = config; + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(apiUrl); + + const url = buildUrl(apiUrl, `/v1beta/models/${selectedModel}:generateContent`, apiKey); + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ role: 'user', parts: [{ text: buildUserPrompt(text) }] }], + tools: [GEMINI_PROOFREAD_TOOL], + toolConfig: { functionCallingConfig: { mode: 'ANY' } }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const parts = (data as GeminiResponse)?.candidates?.[0]?.content?.parts; + const fnCall = parts?.find((p) => p.functionCall?.name === 'report_corrections'); + + if (!fnCall?.functionCall) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections = + (fnCall.functionCall.args as { corrections?: RawCorrection[] })?.corrections ?? []; + + return buildProofreadResult(text, rawCorrections, 'Gemini'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/services/providers/openai-compatible.ts b/src/services/providers/openai-compatible.ts new file mode 100644 index 0000000..cd31a8b --- /dev/null +++ b/src/services/providers/openai-compatible.ts @@ -0,0 +1,159 @@ +import type { IProofreader } from '../proofreader.ts'; +import type { ProofreadResult, ApiConfig } from '../../shared/types.ts'; +import type { ApiProvider, ApiModel, RawCorrection } from './api-provider-utils.ts'; +import { + fetchWithTimeout, + ensureHostPermission, + parseErrorBody, + testConnectionWith, + buildProofreadResult, + normalizeBaseUrl, +} from './api-provider-utils.ts'; +import { logger } from '../logger.ts'; + +const JSON_SYSTEM_PROMPT = `You are a precise proofreading assistant. Detect the language of the input text automatically and identify grammar, spelling, punctuation, capitalization, preposition, and missing-word errors. Only report real errors — not style preferences. + +Rules: +- NEVER correct proper nouns, brand names, product names, or technical terms. +- originalText must be the EXACT substring from the input containing the error. +- correctedText must be a SINGLE direct replacement — never multiple alternatives. +- The replacement must be directly substitutable: replacing originalText with correctedText in the input must produce valid text. + +Respond ONLY with a JSON object in this exact format, no other text: +{"corrections":[{"originalText":"exact error text","correctedText":"single fixed text","type":"spelling|grammar|punctuation|capitalization|preposition|missing-words","explanation":"brief reason"}]} + +If there are no errors, respond with: {"corrections":[]}`; + +function buildHeaders(apiKey: string): Record { + const headers: Record = { + 'content-type': 'application/json', + }; + if (apiKey) { + headers['authorization'] = `Bearer ${apiKey}`; + } + return headers; +} + +function extractJson(text: string): string | null { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenced) return fenced[1].trim(); + + const braces = text.match(/\{[\s\S]*\}/); + if (braces) return braces[0]; + + return null; +} + +function tryParseJson(json: string): unknown | null { + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function repairJson(json: string): string { + return json + .replace(/,\s*([}\]])/g, '$1') + .replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":') + .replace(/:\s*'([^']*)'/g, ':"$1"') + .replace(/"\s*\n\s*"/g, '","'); +} + +function parseCorrections(content: string): RawCorrection[] { + const json = extractJson(content); + if (!json) { + logger.warn({ content }, 'OpenAI-compatible: no JSON found in response'); + return []; + } + + let parsed = tryParseJson(json); + if (!parsed) { + parsed = tryParseJson(repairJson(json)); + } + if (!parsed) { + logger.warn({ json }, 'OpenAI-compatible: failed to parse JSON from response'); + return []; + } + + const corrections = (parsed as { corrections?: unknown }).corrections; + return Array.isArray(corrections) ? (corrections as RawCorrection[]) : []; +} + +export const openaiCompatibleProvider: ApiProvider = { + async testConnection(config: ApiConfig) { + const base = normalizeBaseUrl(config.apiUrl); + return testConnectionWith( + () => fetchWithTimeout(`${base}/v1/models`, { headers: buildHeaders(config.apiKey) }), + parseErrorBody + ); + }, + + async fetchModels(config: ApiConfig): Promise { + const base = normalizeBaseUrl(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/models`, { + headers: buildHeaders(config.apiKey), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const models = (data.data ?? []) as Array<{ id: string; name?: string }>; + return models.map((m) => ({ + id: m.id, + displayName: m.name ?? m.id, + })); + }, + + createProofreader(config: ApiConfig): IProofreader { + return { + async proofread(text: string): Promise { + const { apiKey, selectedModel } = config; + const base = normalizeBaseUrl(config.apiUrl); + + if (!selectedModel) { + throw new Error('No AI model selected. Please choose a model in settings.'); + } + + await ensureHostPermission(config.apiUrl); + + const response = await fetchWithTimeout(`${base}/v1/chat/completions`, { + method: 'POST', + headers: buildHeaders(apiKey), + body: JSON.stringify({ + model: selectedModel, + messages: [ + { role: 'system', content: JSON_SYSTEM_PROMPT }, + { + role: 'user', + content: `Proofread the following text and report all errors:\n\n${text}`, + }, + ], + response_format: { type: 'json_object' }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(parseErrorBody(response, errorData)); + } + + const data = await response.json(); + const content = (data.choices?.[0]?.message?.content as string) ?? ''; + + if (!content) { + return { correctedInput: text, corrections: [] }; + } + + const rawCorrections = parseCorrections(content); + return buildProofreadResult(text, rawCorrections, 'OpenAI-compatible'); + }, + + destroy() {}, + }; + }, +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index 5af1ed2..012f8c1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2,7 +2,7 @@ export type UnderlineStyle = 'solid' | 'wavy' | 'dotted'; export type ModelSource = 'local' | 'api'; -export type ApiType = 'claude' | 'openai-compatible'; +export type ApiType = 'claude' | 'gemini' | 'openai-compatible'; export interface ApiConfig { type: ApiType; From 866dee18ab570d7bc36f3aff3f1f86a0c1fb6cec Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 19:44:56 -0500 Subject: [PATCH 3/9] feat: enhance model readiness checks based on storage values * Updated model readiness logic to check the model source before ensuring the proofreader model is ready. * Refactored the isModelReady function to include model source in its checks. * Improved consistency in handling model readiness across different modules (main, options, sidepanel). --- src/content/main.ts | 9 +++++++-- src/options/main.ts | 2 +- src/shared/utils/storage.ts | 8 ++++---- src/sidepanel/main.ts | 8 ++++++-- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/content/main.ts b/src/content/main.ts index 5104d40..9e72a2e 100644 --- a/src/content/main.ts +++ b/src/content/main.ts @@ -1,6 +1,7 @@ import { logger } from '../services/logger.ts'; import { ProofreadingManager } from './proofreading-manager.ts'; -import { isModelReady } from '../shared/utils/storage.ts'; +import { isModelReady, getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import { ensureProofreaderModelReady } from '../services/model-checker.ts'; import { installDevSidepanelButton } from './dev-sidepanel-button.ts'; @@ -11,7 +12,11 @@ async function initProofreading() { try { await installDevSidepanelButton(); - await ensureProofreaderModelReady(); + + const { modelSource } = await getStorageValues([STORAGE_KEYS.MODEL_SOURCE]); + if (modelSource !== 'api') { + await ensureProofreaderModelReady(); + } const modelReady = await isModelReady(); logger.info({ modelReady }, 'Model ready check:'); diff --git a/src/options/main.ts b/src/options/main.ts index 64387da..4cfb1d9 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -103,7 +103,7 @@ async function initOptions() { await ensureProofreaderModelReady(); } - const modelReady = isApiMode ? true : await isModelReady(); + const modelReady = await isModelReady(); if (!modelReady) { app.innerHTML = ` diff --git a/src/shared/utils/storage.ts b/src/shared/utils/storage.ts index b4e55b5..f564a04 100644 --- a/src/shared/utils/storage.ts +++ b/src/shared/utils/storage.ts @@ -135,15 +135,15 @@ export async function setStorageValues(data: Partial): Promise { - const { proofreaderReady, modelDownloaded } = await getStorageValues([ + const { modelSource, proofreaderReady, modelDownloaded } = await getStorageValues([ + STORAGE_KEYS.MODEL_SOURCE, STORAGE_KEYS.PROOFREADER_READY, STORAGE_KEYS.MODEL_DOWNLOADED, ]); + if (modelSource === 'api') return true; + return proofreaderReady && modelDownloaded; } diff --git a/src/sidepanel/main.ts b/src/sidepanel/main.ts index d0c11c5..8e5d979 100644 --- a/src/sidepanel/main.ts +++ b/src/sidepanel/main.ts @@ -3,7 +3,8 @@ import './components/issues-panel.ts'; import './style.css'; import { logger } from '../services/logger.ts'; -import { isModelReady } from '../shared/utils/storage.ts'; +import { isModelReady, getStorageValues } from '../shared/utils/storage.ts'; +import { STORAGE_KEYS } from '../shared/constants.ts'; import type { ApplyAllIssuesMessage, IssuesStateRequestMessage, @@ -54,7 +55,10 @@ async function initSidepanel(): Promise { document.body.classList.add('prfly-page'); - await ensureProofreaderModelReady(); + const { modelSource } = await getStorageValues([STORAGE_KEYS.MODEL_SOURCE]); + if (modelSource !== 'api') { + await ensureProofreaderModelReady(); + } const modelReady = await isModelReady(); if (!modelReady) { From 1bc1ca6ac9d3ca8b277a454330c7d86eabdd409a Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Fri, 27 Feb 2026 20:04:15 -0500 Subject: [PATCH 4/9] feat: update model source handling in background service * Added retrieval of model source from storage to update the model source cache during badge listener registration. * Modified the API request structure to include the system prompt in the user message for improved context during proofreading. --- src/background/main.ts | 1 + src/services/providers/openai-compatible.ts | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/background/main.ts b/src/background/main.ts index e0291ca..5b48a39 100644 --- a/src/background/main.ts +++ b/src/background/main.ts @@ -306,6 +306,7 @@ function registerBadgeListeners(): void { registerBadgeListeners(); void updateActionBadge(); +void getStorageValue(STORAGE_KEYS.MODEL_SOURCE).then(updateModelSourceCache); chrome.runtime.onInstalled.addListener(async (details) => { await initializeStorage(); diff --git a/src/services/providers/openai-compatible.ts b/src/services/providers/openai-compatible.ts index cd31a8b..6627eb8 100644 --- a/src/services/providers/openai-compatible.ts +++ b/src/services/providers/openai-compatible.ts @@ -127,13 +127,11 @@ export const openaiCompatibleProvider: ApiProvider = { body: JSON.stringify({ model: selectedModel, messages: [ - { role: 'system', content: JSON_SYSTEM_PROMPT }, { role: 'user', - content: `Proofread the following text and report all errors:\n\n${text}`, + content: `${JSON_SYSTEM_PROMPT}\n\nProofread the following text and report all errors:\n\n${text}`, }, ], - response_format: { type: 'json_object' }, }), }); From 921dfb36d03c5fbce9587cd93b15f502ab6a4292 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Tue, 3 Mar 2026 09:31:26 -0500 Subject: [PATCH 5/9] refactor: replace ContentHighlighter with ContentEditableTargetHandler * Removed the ContentHighlighter component and its associated logic from the proofreading manager. * Introduced ContentEditableTargetHandler to manage highlighting and interaction for content-editable elements. * Updated the proofreading manager to utilize the new handler for attaching, highlighting, and managing corrections. * Adjusted tests and popover manager to reflect the removal of ContentHighlighter and ensure compatibility with the new handler. --- src/content/components/content-highlighter.ts | 915 ------------------ .../contenteditable-target-handler.ts | 576 +++++++++++ src/content/handlers/direct-target-handler.ts | 62 -- src/content/proofreading-manager.spec.ts | 18 +- src/content/proofreading-manager.ts | 73 +- src/content/services/popover-manager.spec.ts | 12 - src/content/services/popover-manager.ts | 5 - src/options/main.ts | 107 +- src/options/style.css | 2 - 9 files changed, 682 insertions(+), 1088 deletions(-) delete mode 100644 src/content/components/content-highlighter.ts create mode 100644 src/content/handlers/contenteditable-target-handler.ts delete mode 100644 src/content/handlers/direct-target-handler.ts diff --git a/src/content/components/content-highlighter.ts b/src/content/components/content-highlighter.ts deleted file mode 100644 index c3c4f3c..0000000 --- a/src/content/components/content-highlighter.ts +++ /dev/null @@ -1,915 +0,0 @@ -import { - getActiveCorrectionColors, - type CorrectionColorThemeMap, -} from '../../shared/utils/correction-types.ts'; -import { STORAGE_KEYS } from '../../shared/constants.ts'; -import { getStorageValue, onStorageChange } from '../../shared/utils/storage.ts'; -import type { UnderlineStyle } from '../../shared/types.ts'; -import './correction-popover.ts'; -import type { CorrectionPopover } from './correction-popover.ts'; -import { logger } from '../../services/logger.ts'; -import { replaceTextWithUndo } from '../../shared/utils/clipboard.ts'; - -const ERROR_TYPES = [ - 'spelling', - 'grammar', - 'punctuation', - 'capitalization', - 'preposition', - 'missing-words', -] as const; - -const SELECTED_HIGHLIGHT = 'prfly-selected'; -const PREVIEW_HIGHLIGHT = 'prfly-preview'; - -export class ContentHighlighter { - private highlightedElements = new Map(); - private observers = new Map(); - private highlights = new Map(); - private elementRanges = new Map(); - private popover: CorrectionPopover | null = null; - private clickHandlers = new Map void>(); - private dblClickHandlers = new Map void>(); - private onCorrectionAppliedCallbacks = new Map< - HTMLElement, - (updatedCorrections: ProofreadCorrection[]) => void - >(); - private applyCorrectionCallbacks = new Map< - HTMLElement, - (element: HTMLElement, correction: ProofreadCorrection) => void - >(); - private underlineStyleCleanup: (() => void) | null = null; - private autofixCleanup: (() => void) | null = null; - private selectedHighlight: Highlight | null = null; - private selectedElement: HTMLElement | null = null; - private selectedCorrectionRange: { - start: number; - end: number; - type?: string; - } | null = null; - private previewHighlight: Highlight | null = null; - private previewElement: HTMLElement | null = null; - private previewCorrectionRange: { - start: number; - end: number; - type?: string; - } | null = null; - private popoverHideCleanup: (() => void) | null = null; - private correctionColors: CorrectionColorThemeMap = getActiveCorrectionColors(); - private currentUnderlineStyle: UnderlineStyle = 'solid'; - private autofixOnDoubleClick: boolean = false; - - constructor() { - this.initializeHighlights(); - void this.initializeUnderlineStyle(); - void this.initializeAutofixSetting(); - } - - setOnCorrectionApplied( - element: HTMLElement, - callback: (updatedCorrections: ProofreadCorrection[]) => void - ): void { - this.onCorrectionAppliedCallbacks.set(element, callback); - } - - setApplyCorrectionCallback( - element: HTMLElement, - callback: (element: HTMLElement, correction: ProofreadCorrection) => void - ): void { - this.applyCorrectionCallbacks.set(element, callback); - } - - setCorrectionColors(colors: CorrectionColorThemeMap): void { - this.correctionColors = structuredClone(colors); - this.applyHighlightStyles(); - if (this.selectedCorrectionRange) { - setSelectedHighlightColors(this.selectedCorrectionRange.type, this.correctionColors); - } - if (this.previewCorrectionRange) { - setPreviewHighlightColors(this.previewCorrectionRange.type, this.correctionColors); - } - } - - setPopover(popover: CorrectionPopover | null): void { - if (this.popover === popover) { - return; - } - - if (this.popoverHideCleanup) { - this.popoverHideCleanup(); - this.popoverHideCleanup = null; - } - - this.popover = popover; - - if (!this.popover) { - return; - } - - const handlePopoverHide = () => { - this.clearSelectedCorrection(); - }; - - this.popover.addEventListener('proofly:popover-hide', handlePopoverHide); - this.popoverHideCleanup = () => { - this.popover?.removeEventListener('proofly:popover-hide', handlePopoverHide); - }; - } - - previewCorrection(element: HTMLElement, correction: ProofreadCorrection): void { - this.highlightPreviewCorrection(element, correction); - } - - clearPreview(): void { - this.clearPreviewCorrection(); - } - - private initializeHighlights(): void { - if (!('highlights' in CSS)) { - logger.warn('CSS Custom Highlights API not supported'); - return; - } - - for (const errorType of ERROR_TYPES) { - const highlight = new Highlight(); - this.highlights.set(errorType, highlight); - CSS.highlights.set(errorType, highlight); - } - - this.selectedHighlight = new Highlight(); - CSS.highlights.set(SELECTED_HIGHLIGHT, this.selectedHighlight); - - this.previewHighlight = new Highlight(); - CSS.highlights.set(PREVIEW_HIGHLIGHT, this.previewHighlight); - } - - private async initializeUnderlineStyle(): Promise { - if (!('highlights' in CSS)) { - return; - } - - this.currentUnderlineStyle = await loadUnderlineStyle(); - this.applyHighlightStyles(); - - this.underlineStyleCleanup = onStorageChange(STORAGE_KEYS.UNDERLINE_STYLE, (newValue) => { - this.currentUnderlineStyle = newValue; - this.applyHighlightStyles(); - }); - } - - private async initializeAutofixSetting(): Promise { - try { - this.autofixOnDoubleClick = await getStorageValue(STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK); - } catch (error) { - logger.error(error, 'Failed to load autofix setting'); - this.autofixOnDoubleClick = false; - } - - this.autofixCleanup = onStorageChange(STORAGE_KEYS.AUTOFIX_ON_DOUBLE_CLICK, (newValue) => { - this.autofixOnDoubleClick = newValue; - }); - } - - private applyHighlightStyles(): void { - if (!('highlights' in CSS)) { - return; - } - - updateHighlightStyle(this.currentUnderlineStyle, this.correctionColors); - } - - highlight(element: HTMLElement, corrections: ProofreadCorrection[]): void { - if (!this.isEditableElement(element)) return; - - const text = this.getElementText(element); - if (!text) return; - - this.highlightedElements.set(element, corrections); - this.applyHighlights(element, corrections); - this.attachClickHandler(element); - } - - private attachClickHandler(element: HTMLElement): void { - if (this.clickHandlers.has(element)) return; - - const clickHandler = (e: MouseEvent) => { - this.handleElementClick(element, e); - }; - - const dblClickHandler = (e: MouseEvent) => { - this.handleElementDoubleClick(element, e); - }; - - element.addEventListener('click', clickHandler); - element.addEventListener('dblclick', dblClickHandler); - this.clickHandlers.set(element, clickHandler); - this.dblClickHandlers.set(element, dblClickHandler); - } - - private handleElementClick(element: HTMLElement, event: MouseEvent): void { - // If autofix is enabled, prevent popover on single click - if (this.autofixOnDoubleClick) { - return; - } - - if (!this.popover) { - logger.warn('Popover not initialized'); - return; - } - - const corrections = this.highlightedElements.get(element); - if (!corrections || corrections.length === 0) { - logger.info('No corrections found for element'); - return; - } - - // Find which correction was clicked using CSS.highlights API - const clickedCorrection = this.findCorrectionAtPoint( - element, - event.clientX, - event.clientY, - corrections - ); - - logger.info( - { - clientX: event.clientX, - clientY: event.clientY, - }, - 'Click detected at coordinates:' - ); - - logger.info(corrections, 'Available corrections'); - logger.info(clickedCorrection, 'Found correction'); - - if (!clickedCorrection) { - try { - this.popover.hidePopover(); - } catch (_error) { - logger.warn({ error: _error }, 'Failed to hide popover'); - } - this.clearSelectedCorrection(); - return; - } - - this.highlightSelectedCorrection(element, clickedCorrection); - - const elementText = this.getElementText(element); - const issueText = elementText.substring( - clickedCorrection.startIndex, - clickedCorrection.endIndex - ); - - this.popover.setCorrection(clickedCorrection, issueText, (appliedCorrection) => { - this.applyCorrection(element, appliedCorrection); - }); - - const correctionRect = this.getCorrectionBoundingRect(element, clickedCorrection); - const x = correctionRect ? correctionRect.left + correctionRect.width / 2 : event.clientX; - const y = correctionRect ? correctionRect.bottom + 8 : event.clientY + 20; - - const positionResolver = () => { - const rect = this.getCorrectionBoundingRect(element, clickedCorrection); - if (!rect) { - return null; - } - return { - x: rect.left + rect.width / 2, - y: rect.bottom + 8, - }; - }; - - logger.info({ x, y }, 'Showing popover at'); - this.popover.show(x, y, { anchorElement: element, positionResolver }); - } - - private handleElementDoubleClick(element: HTMLElement, event: MouseEvent): void { - // Only process double-click if autofix is enabled - if (!this.autofixOnDoubleClick) { - return; - } - - const corrections = this.highlightedElements.get(element); - if (!corrections || corrections.length === 0) { - logger.info('No corrections found for element'); - return; - } - - // Find which correction was double-clicked using CSS.highlights API - const clickedCorrection = this.findCorrectionAtPoint( - element, - event.clientX, - event.clientY, - corrections - ); - - logger.info( - { - clientX: event.clientX, - clientY: event.clientY, - }, - 'Double-click detected at coordinates:' - ); - - logger.info(clickedCorrection, 'Found correction for autofix'); - - if (!clickedCorrection) { - return; - } - - // Apply correction immediately without showing popover - this.applyCorrection(element, clickedCorrection); - } - - private findCorrectionAtPoint( - element: HTMLElement, - x: number, - y: number, - corrections: ProofreadCorrection[] - ): ProofreadCorrection | null { - // First try: use caretRangeFromPoint/caretPositionFromPoint with the element context - const caretBasedCorrection = this.findCorrectionUsingCaret(element, x, y, corrections); - if (caretBasedCorrection) { - return caretBasedCorrection; - } - - // Fallback: Check if CSS.highlights.highlightsFromPoint is available - if ('highlights' in CSS && 'highlightsFromPoint' in CSS.highlights) { - const highlightsAtPoint = (CSS.highlights as any).highlightsFromPoint(x, y); - - // Find which error type was clicked - for (const highlight of highlightsAtPoint) { - const errorType = ERROR_TYPES.find((type) => this.highlights.get(type) === highlight); - if (errorType) { - // Return the first correction of this type near the click point - // Since we can't get the exact range from highlightsFromPoint, - // we'll need to check all corrections of this type - return corrections.find((c) => c.type === errorType) || null; - } - } - } - - return null; - } - - private findCorrectionUsingCaret( - element: HTMLElement, - x: number, - y: number, - corrections: ProofreadCorrection[] - ): ProofreadCorrection | null { - // Get the text node within the element - const textNode = this.getFirstTextNode(element); - if (!textNode || !textNode.textContent) { - logger.info('No text node found in element'); - return null; - } - - const text = textNode.textContent; - const textLength = text.length; - - // Find which character position is closest to the click coordinates - let closestOffset = 0; - let minDistance = Infinity; - - for (let i = 0; i < textLength; i++) { - const range = document.createRange(); - try { - range.setStart(textNode, i); - range.setEnd(textNode, Math.min(i + 1, textLength)); - const rect = range.getBoundingClientRect(); - - // Calculate distance from click point to character center - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const distance = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)); - - if (distance < minDistance) { - minDistance = distance; - closestOffset = i; - } - } catch (_error) { - // Skip if range creation fails - continue; - } - } - - logger.info(`Closest character offset ${closestOffset} at distance ${minDistance}`); - logger.info( - `Looking for correction at offset ${closestOffset} in ${corrections.length} corrections` - ); - - // Find correction that contains this offset - const found = corrections.find( - (c) => c.startIndex <= closestOffset && closestOffset < c.endIndex - ); - - if (found) { - logger.info({ found }, 'Found correction'); - } else { - logger.info({ closestOffset }, 'No correction found at offset'); - logger.info( - { - correctionRanges: corrections.map((c) => `[${c.startIndex}-${c.endIndex}]`).join(', '), - }, - 'Available correction ranges' - ); - } - - return found || null; - } - - private highlightSelectedCorrection(element: HTMLElement, correction: ProofreadCorrection): void { - if (!('highlights' in CSS) || !this.selectedHighlight) { - return; - } - - const textNode = this.getFirstTextNode(element); - if (!textNode) { - return; - } - - const range = createCorrectionRange(textNode, correction.startIndex, correction.endIndex); - if (!range) { - return; - } - - this.selectedHighlight.clear(); - this.selectedHighlight.add(range); - this.selectedElement = element; - this.selectedCorrectionRange = { - start: correction.startIndex, - end: correction.endIndex, - type: correction.type, - }; - setSelectedHighlightColors(correction.type, this.correctionColors); - } - - private highlightPreviewCorrection(element: HTMLElement, correction: ProofreadCorrection): void { - if (!('highlights' in CSS) || !this.previewHighlight) { - return; - } - - const textNode = this.getFirstTextNode(element); - if (!textNode) { - this.clearPreviewCorrection(); - return; - } - - const range = createCorrectionRange(textNode, correction.startIndex, correction.endIndex); - if (!range) { - this.clearPreviewCorrection(); - return; - } - - this.previewHighlight.clear(); - this.previewHighlight.add(range); - this.previewElement = element; - this.previewCorrectionRange = { - start: correction.startIndex, - end: correction.endIndex, - type: correction.type, - }; - setPreviewHighlightColors(correction.type, this.correctionColors); - } - - private clearSelectedCorrection(): void { - if (this.selectedHighlight) { - this.selectedHighlight.clear(); - } - - this.selectedElement = null; - this.selectedCorrectionRange = null; - clearSelectedHighlightColors(); - } - - private clearPreviewCorrection(): void { - if (this.previewHighlight) { - this.previewHighlight.clear(); - } - - this.previewElement = null; - this.previewCorrectionRange = null; - clearPreviewHighlightColors(); - } - - private clearPreviewForElement(element: HTMLElement): void { - if (this.previewElement === element) { - this.clearPreviewCorrection(); - } - } - - private reapplySelectedHighlight(element: HTMLElement, textNode: Text): void { - if ( - !('highlights' in CSS) || - !this.selectedHighlight || - this.selectedElement !== element || - !this.selectedCorrectionRange - ) { - return; - } - - const range = createCorrectionRange( - textNode, - this.selectedCorrectionRange.start, - this.selectedCorrectionRange.end - ); - - if (!range) { - this.clearSelectedCorrection(); - return; - } - - this.selectedHighlight.clear(); - this.selectedHighlight.add(range); - setSelectedHighlightColors(this.selectedCorrectionRange.type, this.correctionColors); - } - - private reapplyPreviewHighlight(element: HTMLElement, textNode: Text): void { - if ( - !('highlights' in CSS) || - !this.previewHighlight || - this.previewElement !== element || - !this.previewCorrectionRange - ) { - return; - } - - const range = createCorrectionRange( - textNode, - this.previewCorrectionRange.start, - this.previewCorrectionRange.end - ); - - if (!range) { - this.clearPreviewCorrection(); - return; - } - - this.previewHighlight.clear(); - this.previewHighlight.add(range); - setPreviewHighlightColors(this.previewCorrectionRange.type, this.correctionColors); - } - - private getCorrectionBoundingRect( - element: HTMLElement, - correction: ProofreadCorrection - ): DOMRect | null { - const textNode = this.getFirstTextNode(element); - if (!textNode) { - return null; - } - - const range = createCorrectionRange(textNode, correction.startIndex, correction.endIndex); - if (!range) { - return null; - } - - const rects = range.getClientRects(); - if (rects.length > 0) { - return rects[0]; - } - - const rect = range.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) { - return null; - } - - return rect; - } - - private applyCorrection(element: HTMLElement, correction: ProofreadCorrection): void { - // Check if there's a callback to handle the correction application - const applyCallback = this.applyCorrectionCallbacks.get(element); - if (applyCallback) { - applyCallback(element, correction); - return; - } - - // Fallback: apply directly with undo support (for non-mirror elements like live test editor) - replaceTextWithUndo(element, correction.startIndex, correction.endIndex, correction.correction); - - const lengthDiff = correction.correction.length - (correction.endIndex - correction.startIndex); - - const corrections = this.highlightedElements.get(element); - if (corrections) { - const updatedCorrections = corrections - .filter((c) => c !== correction) - .map((c) => { - if (c.startIndex > correction.startIndex) { - return { - ...c, - startIndex: c.startIndex + lengthDiff, - endIndex: c.endIndex + lengthDiff, - }; - } - return c; - }); - - this.highlightedElements.set(element, updatedCorrections); - - if (updatedCorrections.length > 0) { - this.applyHighlights(element, updatedCorrections); - } else { - this.clearHighlights(element); - } - - const callback = this.onCorrectionAppliedCallbacks.get(element); - if (callback) { - callback(updatedCorrections); - } - } - - this.clearSelectedCorrection(); - this.clearPreviewForElement(element); - CSS.highlights.delete(SELECTED_HIGHLIGHT); - this.selectedHighlight = new Highlight(); - CSS.highlights.set(SELECTED_HIGHLIGHT, this.selectedHighlight); - } - - clearHighlights(element: HTMLElement): void { - this.highlightedElements.delete(element); - this.elementRanges.delete(element); - this.removeHighlights(element); - this.clearSelectedCorrection(); - this.clearPreviewForElement(element); - } - - clearAllHighlights(): void { - for (const element of this.highlightedElements.keys()) { - this.clearHighlights(element); - } - this.elementRanges.clear(); - this.clearPreviewCorrection(); - } - - private isEditableElement(element: HTMLElement): boolean { - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'textarea' || tagName === 'input') { - return true; - } - - return element.isContentEditable || element.hasAttribute('contenteditable'); - } - - private getElementText(element: HTMLElement): string { - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'textarea' || tagName === 'input') { - return (element as HTMLInputElement | HTMLTextAreaElement).value; - } - - return element.textContent || ''; - } - - private applyHighlights(element: HTMLElement, corrections: ProofreadCorrection[]): void { - const tagName = element.tagName.toLowerCase(); - - if (tagName === 'textarea' || tagName === 'input') { - logger.warn('CSS Custom Highlights API does not support input/textarea elements'); - return; - } - - this.highlightContentEditableElement(element, corrections); - } - - private highlightContentEditableElement( - element: HTMLElement, - corrections: ProofreadCorrection[] - ): void { - if (!('highlights' in CSS)) { - return; - } - - element.setAttribute('data-proofly-contenteditable', 'true'); - - this.clearHighlightsForElement(element); - - const textNode = this.getFirstTextNode(element); - if (!textNode) { - return; - } - - const ranges: Range[] = []; - - for (const correction of corrections) { - const range = new Range(); - try { - range.setStart(textNode, correction.startIndex); - range.setEnd(textNode, correction.endIndex); - - const errorType = correction.type || 'spelling'; - const highlight = this.highlights.get(errorType); - if (highlight) { - highlight.add(range); - ranges.push(range); - } - } catch (error) { - logger.warn(error, 'Failed to create highlight range'); - } - } - - // Track ranges for this element - this.elementRanges.set(element, ranges); - - this.reapplySelectedHighlight(element, textNode); - this.reapplyPreviewHighlight(element, textNode); - } - - private getFirstTextNode(element: HTMLElement): Text | null { - if (element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE) { - return element.firstChild as Text; - } - - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); - return walker.nextNode() as Text | null; - } - - private clearHighlightsForElement(element: HTMLElement): void { - // Remove only the ranges that belong to this element - const ranges = this.elementRanges.get(element); - if (ranges) { - for (const range of ranges) { - // Remove this range from all highlight registries - for (const highlight of this.highlights.values()) { - highlight.delete(range); - } - } - this.elementRanges.delete(element); - } - - if (this.selectedElement === element) { - this.selectedHighlight?.clear(); - } - } - - private removeHighlights(element: HTMLElement): void { - this.clearHighlightsForElement(element); - this.observers.get(element)?.disconnect(); - this.observers.delete(element); - - const clickHandler = this.clickHandlers.get(element); - if (clickHandler) { - element.removeEventListener('click', clickHandler); - this.clickHandlers.delete(element); - } - - const dblClickHandler = this.dblClickHandlers.get(element); - if (dblClickHandler) { - element.removeEventListener('dblclick', dblClickHandler); - this.dblClickHandlers.delete(element); - } - } - - destroy(): void { - this.clearAllHighlights(); - this.observers.forEach((observer) => observer.disconnect()); - this.observers.clear(); - - this.clickHandlers.forEach((handler, element) => { - element.removeEventListener('click', handler); - }); - this.clickHandlers.clear(); - - this.dblClickHandlers.forEach((handler, element) => { - element.removeEventListener('dblclick', handler); - }); - this.dblClickHandlers.clear(); - - this.setPopover(null); - - if ('highlights' in CSS) { - for (const errorType of ERROR_TYPES) { - CSS.highlights.delete(errorType); - } - CSS.highlights.delete(SELECTED_HIGHLIGHT); - CSS.highlights.delete(PREVIEW_HIGHLIGHT); - } - this.highlights.clear(); - - if (this.underlineStyleCleanup) { - this.underlineStyleCleanup(); - this.underlineStyleCleanup = null; - } - - if (this.autofixCleanup) { - this.autofixCleanup(); - this.autofixCleanup = null; - } - - this.selectedHighlight = null; - this.selectedElement = null; - clearSelectedHighlightColors(); - this.previewHighlight = null; - this.previewElement = null; - this.previewCorrectionRange = null; - clearPreviewHighlightColors(); - } - - clearSelection(): void { - this.clearSelectedCorrection(); - } -} - -const HIGHLIGHT_STYLE_ID = 'proofly-highlight-style'; -let highlightStyleElement: HTMLStyleElement | null = null; - -function ensureHighlightStyleElement(): HTMLStyleElement { - if (highlightStyleElement && document.head.contains(highlightStyleElement)) { - return highlightStyleElement; - } - - highlightStyleElement = document.getElementById(HIGHLIGHT_STYLE_ID) as HTMLStyleElement | null; - if (!highlightStyleElement) { - highlightStyleElement = document.createElement('style'); - highlightStyleElement.id = HIGHLIGHT_STYLE_ID; - document.head.appendChild(highlightStyleElement); - } - - return highlightStyleElement; -} - -function updateHighlightStyle(style: UnderlineStyle, colors: CorrectionColorThemeMap): void { - if (!('highlights' in CSS)) { - return; - } - - const styleElement = ensureHighlightStyleElement(); - styleElement.textContent = ERROR_TYPES.map((errorType) => { - const theme = colors[errorType]; - return ` - [data-proofly-contenteditable]::highlight(${errorType}) { - background-color: transparent; - text-decoration: underline; - text-decoration-color: ${theme.color}; - text-decoration-thickness: 2px; - text-decoration-skip-ink: none; - text-decoration-style: ${style}; - text-underline-offset: 2px; - }`; - }).join('\n').concat(` - [data-proofly-contenteditable]::highlight(${SELECTED_HIGHLIGHT}) { - background-color: var(--prfly-selected-highlight-bg, transparent); - } - - [data-proofly-contenteditable]::highlight(${PREVIEW_HIGHLIGHT}) { - background-color: var(--prfly-preview-highlight-bg, transparent); - } -`); -} - -async function loadUnderlineStyle(): Promise { - try { - return await getStorageValue(STORAGE_KEYS.UNDERLINE_STYLE); - } catch (error) { - logger.error(error, 'Failed to load underline style for highlights'); - return 'solid'; - } -} - -if ('highlights' in CSS) { - updateHighlightStyle('solid', getActiveCorrectionColors()); -} - -function setSelectedHighlightColors( - type: string | undefined, - colors: CorrectionColorThemeMap -): void { - const theme = type - ? colors[type as keyof CorrectionColorThemeMap] || colors.spelling - : colors.spelling; - document.documentElement.style.setProperty('--prfly-selected-highlight-bg', theme.background); - document.documentElement.style.setProperty('--prfly-selected-highlight-color', theme.color); -} - -function clearSelectedHighlightColors(): void { - document.documentElement.style.removeProperty('--prfly-selected-highlight-bg'); - document.documentElement.style.removeProperty('--prfly-selected-highlight-color'); -} - -function setPreviewHighlightColors( - type: string | undefined, - colors: CorrectionColorThemeMap -): void { - const theme = type - ? colors[type as keyof CorrectionColorThemeMap] || colors.spelling - : colors.spelling; - document.documentElement.style.setProperty('--prfly-preview-highlight-bg', theme.background); -} - -function clearPreviewHighlightColors(): void { - document.documentElement.style.removeProperty('--prfly-preview-highlight-bg'); -} - -function createCorrectionRange(textNode: Text, start: number, end: number): Range | null { - const range = new Range(); - try { - range.setStart(textNode, start); - range.setEnd(textNode, end); - return range; - } catch (error) { - logger.warn(error, 'Failed to create selected correction range:'); - return null; - } -} diff --git a/src/content/handlers/contenteditable-target-handler.ts b/src/content/handlers/contenteditable-target-handler.ts new file mode 100644 index 0000000..0fce9f2 --- /dev/null +++ b/src/content/handlers/contenteditable-target-handler.ts @@ -0,0 +1,576 @@ +import type { TargetHandler } from './target-handler.ts'; +import type { ProofreadCorrection, UnderlineStyle } from '../../shared/types.ts'; +import type { IssueColorPalette } from '../target-session.ts'; +import { UnderlineRenderer, type UnderlineDescriptor, type IssueType } from '../renderer.ts'; +import { createRafScheduler } from '../sync.ts'; +import { + getCorrectionTypeColor, + type CorrectionTypeKey, +} from '../../shared/utils/correction-types.ts'; + +interface ContentEditableHandlerOptions { + onUnderlineClick: (issueId: string, pageRect: DOMRect, anchorNode: HTMLElement) => void; + onUnderlineDoubleClick: (issueId: string) => void; + onInvalidateIssues: () => void; + initialPalette: IssueColorPalette; + initialUnderlineStyle: UnderlineStyle; + initialAutofixOnDoubleClick: boolean; +} + +interface Issue { + id: string; + start: number; + end: number; + type: IssueType; + label: string; +} + +export class ContentEditableTargetHandler implements TargetHandler { + private readonly overlay: ContentEditableOverlay; + private readonly renderer: UnderlineRenderer; + private readonly raf = createRafScheduler(() => this.flushFrame()); + + private attached = false; + private overlayMounted = false; + private needsLayout = false; + private needsRender = false; + private needsMeasurement = false; + + private issues: Issue[] = []; + private colorPalette: IssueColorPalette; + private underlineStyle: UnderlineStyle; + private autofixOnDoubleClick: boolean; + private activeIssueId: string | null = null; + private previewIssueId: string | null = null; + private measuredDescriptors: UnderlineDescriptor[] = []; + private resizeObserver: ResizeObserver | null = null; + private scrollParent: Element | null = null; + + constructor( + public readonly element: HTMLElement, + private readonly options: ContentEditableHandlerOptions + ) { + this.overlay = new ContentEditableOverlay(element); + this.renderer = new UnderlineRenderer(this.overlay.underlines); + this.colorPalette = options.initialPalette; + this.underlineStyle = options.initialUnderlineStyle; + this.autofixOnDoubleClick = options.initialAutofixOnDoubleClick; + } + + attach(): void { + if (this.attached) { + return; + } + + const { underlines } = this.overlay; + + this.element.addEventListener('input', this.handleInput); + underlines.addEventListener('pointerdown', this.handlePointerDown, { capture: true }); + underlines.addEventListener('click', this.handleClick); + underlines.addEventListener('dblclick', this.handleDoubleClick); + window.addEventListener('scroll', this.handleWindowScroll, true); + window.addEventListener('resize', this.handleWindowResize); + + this.scrollParent = findScrollParent(this.element); + if (this.scrollParent) { + this.scrollParent.addEventListener('scroll', this.handleParentScroll, { passive: true }); + } + + this.resizeObserver = new ResizeObserver(() => { + this.needsLayout = true; + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + }); + this.resizeObserver.observe(this.element); + + this.attached = true; + } + + detach(): void { + if (!this.attached) { + return; + } + + this.raf.cancel(); + this.element.removeEventListener('input', this.handleInput); + + const { underlines } = this.overlay; + underlines.removeEventListener('pointerdown', this.handlePointerDown, { capture: true }); + underlines.removeEventListener('click', this.handleClick); + underlines.removeEventListener('dblclick', this.handleDoubleClick); + + window.removeEventListener('scroll', this.handleWindowScroll, true); + window.removeEventListener('resize', this.handleWindowResize); + + if (this.scrollParent) { + this.scrollParent.removeEventListener('scroll', this.handleParentScroll); + this.scrollParent = null; + } + + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.detachOverlay(); + this.attached = false; + } + + highlight(corrections: ProofreadCorrection[]): void { + const elementText = this.element.textContent || ''; + this.issues = mapCorrectionsToIssues(corrections, elementText); + + if (this.issues.length > 0) { + this.ensureOverlayMounted(); + } else { + this.detachOverlay(); + } + + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + } + + clearHighlights(): void { + this.issues = []; + this.activeIssueId = null; + this.previewIssueId = null; + this.detachOverlay(); + } + + clearSelection(): void { + this.activeIssueId = null; + this.previewIssueId = null; + this.needsRender = true; + this.raf.schedule(); + } + + previewIssue(issueId: string | null): void { + if (this.previewIssueId === issueId) { + return; + } + this.previewIssueId = issueId; + this.needsRender = true; + this.raf.schedule(); + } + + updatePreferences(prefs: { + colorPalette?: IssueColorPalette; + underlineStyle?: UnderlineStyle; + autofixOnDoubleClick?: boolean; + }): void { + if (prefs.colorPalette) { + this.colorPalette = prefs.colorPalette; + } + if (prefs.underlineStyle) { + this.underlineStyle = prefs.underlineStyle; + } + if (prefs.autofixOnDoubleClick !== undefined) { + this.autofixOnDoubleClick = prefs.autofixOnDoubleClick; + } + if (this.attached) { + this.needsRender = true; + this.raf.schedule(); + } + } + + dispose(): void { + this.detach(); + this.overlay.destroy(); + } + + private readonly handleInput = () => { + this.options.onInvalidateIssues(); + }; + + private readonly handleWindowScroll = () => { + this.needsLayout = true; + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + }; + + private readonly handleWindowResize = () => { + this.needsLayout = true; + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + }; + + private readonly handleParentScroll = () => { + this.needsLayout = true; + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + }; + + private readonly handlePointerDown = (event: PointerEvent) => { + const node = event.target as HTMLElement | null; + if (!node || !node.classList.contains('u')) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.element.focus({ preventScroll: true }); + }; + + private readonly handleClick = (event: MouseEvent) => { + if (this.autofixOnDoubleClick) { + return; + } + const node = event.target as HTMLElement | null; + if (!node || !node.classList.contains('u')) { + return; + } + this.activateIssueFromNode(node); + }; + + private readonly handleDoubleClick = (event: MouseEvent) => { + if (!this.autofixOnDoubleClick) { + return; + } + const node = event.target as HTMLElement | null; + if (!node || !node.classList.contains('u')) { + return; + } + const issueId = node.dataset.issueId; + if (issueId) { + this.options.onUnderlineDoubleClick(issueId); + } + }; + + private activateIssueFromNode(node: HTMLElement): void { + const issueId = node.dataset.issueId; + if (!issueId) { + return; + } + const rect = node.getBoundingClientRect(); + const pageRect = new DOMRect(rect.left, rect.top + 10, rect.width, rect.height); + this.activeIssueId = issueId; + this.needsRender = true; + this.raf.schedule(); + this.options.onUnderlineClick(issueId, pageRect, node); + } + + private ensureOverlayMounted(): void { + if (this.overlayMounted) { + return; + } + this.overlay.attach(); + this.overlayMounted = true; + this.needsLayout = true; + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + } + + private detachOverlay(): void { + if (!this.overlayMounted) { + return; + } + this.renderer.clear(); + this.overlay.detach(); + this.overlayMounted = false; + } + + private flushFrame(): void { + if (!this.attached || !this.overlayMounted) { + return; + } + + if (this.needsLayout) { + this.overlay.syncPosition(); + this.needsLayout = false; + } + + if (this.needsMeasurement) { + this.measureIssues(); + this.needsMeasurement = false; + this.needsRender = true; + } + + if (this.needsRender) { + this.render(); + this.needsRender = false; + } + } + + private measureIssues(): void { + const fieldRect = this.element.getBoundingClientRect(); + const descriptors: UnderlineDescriptor[] = []; + + for (const issue of this.issues) { + if (issue.end <= issue.start) { + continue; + } + + const rects = getContentEditableRects(this.element, issue.start, issue.end); + let index = 0; + + for (const rect of rects) { + if (rect.width === 0 || rect.height === 0) { + index++; + continue; + } + descriptors.push({ + key: `${issue.id}:${index}`, + issueId: issue.id, + type: issue.type, + rectIndex: index, + rect: new DOMRect( + rect.left - fieldRect.left, + rect.top - fieldRect.top, + rect.width, + rect.height + ), + label: issue.label, + }); + index++; + } + } + + this.measuredDescriptors = descriptors; + } + + private render(): void { + const computed = getComputedStyle(this.element); + const lineHeight = parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.2; + + this.renderer.render(this.measuredDescriptors, { + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + scrollLeft: 0, + scrollTop: 0, + clientWidth: this.element.clientWidth, + clientHeight: this.element.clientHeight, + lineHeight, + margin: 200, + activeIssueId: this.activeIssueId, + previewIssueId: this.previewIssueId, + palette: this.colorPalette, + underlineStyle: this.underlineStyle, + }); + } +} + +class ContentEditableOverlay { + private readonly host: HTMLElement; + private readonly shadow: ShadowRoot; + readonly underlines: HTMLDivElement; + private mounted = false; + + constructor(private readonly field: HTMLElement) { + this.host = document.createElement('prfly-ce-overlay'); + this.host.setAttribute('role', 'presentation'); + Object.assign(this.host.style, { + position: 'fixed', + pointerEvents: 'none', + overflow: 'hidden', + zIndex: String(computeZIndex(field)), + }); + + this.shadow = this.host.attachShadow({ mode: 'open' }); + + const waveMask = `url("data:image/svg+xml;utf8,${encodeURIComponent( + '' + + '' + + '' + )}")`; + + const style = document.createElement('style'); + style.textContent = ` + :host { all: initial; contain: layout paint style; } + #underlines { + position: absolute; + inset: 0; + pointer-events: none; + } + .u { + position: absolute; + pointer-events: auto; + border-radius: 4px; + color: rgba(220, 38, 38, 0.9); + --fill-color: rgba(220, 38, 38, 0.16); + } + .u::before, .u::after { + content: ''; + position: absolute; + left: 0; + right: 0; + } + .u::before { + top: 0; bottom: 0; + opacity: 0; + transition: opacity 120ms ease; + background-color: var(--fill-color); + } + .u::after { + height: var(--underline-height, 3px); + bottom: var(--underline-offset, 2px); + border-radius: 999px; + background-color: currentColor; + } + .u[data-active="true"]::before, + .u[data-preview="true"]::before { opacity: 1; } + .u[data-underline-style="solid"]::after { + -webkit-mask-image: none; + mask-image: none; + } + .u[data-underline-style="dotted"]::after { + border-bottom: currentColor dotted 2px; + background: none; + } + .u[data-underline-style="wavy"]::after { + mask-image: ${waveMask}; + mask-size: 12px 6px; + mask-repeat: repeat-x; + mask-position: left calc(100% + 1px); + } + `; + + this.underlines = document.createElement('div'); + this.underlines.id = 'underlines'; + this.shadow.append(style, this.underlines); + } + + attach(): void { + if (this.mounted) { + return; + } + const container = this.getContainer(); + container?.appendChild(this.host); + this.mounted = true; + this.syncPosition(); + } + + detach(): void { + if (!this.mounted) { + return; + } + this.host.remove(); + this.mounted = false; + } + + syncPosition(): void { + const rect = this.field.getBoundingClientRect(); + Object.assign(this.host.style, { + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + }); + } + + destroy(): void { + this.detach(); + } + + private getContainer(): HTMLElement | null { + const dialog = this.field.closest('dialog') as HTMLDialogElement | null; + if (dialog && dialog.open) { + return dialog; + } + return this.field.ownerDocument?.body ?? document.body; + } +} + +function getContentEditableRects( + field: HTMLElement, + startIndex: number, + endIndex: number +): DOMRect[] { + const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT); + let offset = 0; + let startNode: Text | null = null; + let startLocal = 0; + let endNode: Text | null = null; + let endLocal = 0; + let node: Text | null; + + while ((node = walker.nextNode() as Text | null)) { + const len = node.length; + + if (!startNode && offset + len > startIndex) { + startNode = node; + startLocal = startIndex - offset; + } + + if (offset + len >= endIndex) { + endNode = node; + endLocal = endIndex - offset; + break; + } + + offset += len; + } + + if (!startNode || !endNode) { + return []; + } + + try { + const range = document.createRange(); + range.setStart(startNode, Math.max(0, startLocal)); + range.setEnd(endNode, Math.min(endNode.length, endLocal)); + return Array.from(range.getClientRects()); + } catch { + return []; + } +} + +function findScrollParent(el: Element): Element | null { + let parent = el.parentElement; + while (parent) { + const { overflow, overflowY } = getComputedStyle(parent); + if (/auto|scroll/.test(overflow + overflowY)) { + return parent; + } + parent = parent.parentElement; + } + return null; +} + +function computeZIndex(target: HTMLElement): number { + const value = Number.parseInt(getComputedStyle(target).zIndex ?? '', 10); + return Number.isFinite(value) ? value + 1 : 1; +} + +function mapCorrectionsToIssues(corrections: ProofreadCorrection[], elementText: string): Issue[] { + return corrections + .filter((c) => c.endIndex > c.startIndex) + .map((correction, index) => ({ + id: `${correction.startIndex}:${correction.endIndex}:${index}`, + start: correction.startIndex, + end: correction.endIndex, + type: ((correction.type as CorrectionTypeKey) || 'spelling') as IssueType, + label: buildIssueLabel(correction, elementText), + })); +} + +function buildIssueLabel(correction: ProofreadCorrection, elementText: string): string { + const paletteEntry = getCorrectionTypeColor(correction.type); + const suggestion = correction.correction; + + if (typeof suggestion === 'string') { + if (suggestion === ' ') { + return `${paletteEntry.label} suggestion: space character`; + } + if (suggestion === '') { + return `${paletteEntry.label} suggestion: remove highlighted text`; + } + if (suggestion.trim().length > 0) { + return `${paletteEntry.label} suggestion: ${suggestion.trim()}`; + } + return `${paletteEntry.label} suggestion: whitespace adjustment`; + } + + const maxLen = elementText.length; + const safeStart = Math.max(0, Math.min(correction.startIndex, maxLen)); + const safeEnd = Math.max(safeStart, Math.min(correction.endIndex, maxLen)); + const original = elementText.slice(safeStart, safeEnd).trim(); + if (original.length > 0) { + return `${paletteEntry.label} issue: ${original}`; + } + + return `${paletteEntry.label} suggestion`; +} diff --git a/src/content/handlers/direct-target-handler.ts b/src/content/handlers/direct-target-handler.ts deleted file mode 100644 index 2e94621..0000000 --- a/src/content/handlers/direct-target-handler.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { TargetHandler } from './target-handler.ts'; -import type { ContentHighlighter } from '../components/content-highlighter.ts'; -import type { ProofreadCorrection, UnderlineStyle } from '../../shared/types.ts'; -import type { IssueColorPalette } from '../target-session.ts'; - -interface DirectTargetHandlerOptions { - highlighter: ContentHighlighter; - onCorrectionApplied: (corrections: ProofreadCorrection[]) => void; - onApplyCorrection: (correction: ProofreadCorrection) => void; -} - -export class DirectTargetHandler implements TargetHandler { - constructor( - public readonly element: HTMLElement, - private readonly options: DirectTargetHandlerOptions - ) {} - - attach(): void { - this.options.highlighter.setApplyCorrectionCallback(this.element, (_target, correction) => { - this.options.onApplyCorrection(correction); - }); - - this.options.highlighter.setOnCorrectionApplied(this.element, (updatedCorrections) => { - this.options.onCorrectionApplied(updatedCorrections); - }); - } - - detach(): void { - // ContentHighlighter doesn't have a specific detach for callbacks per element exposed easily - // but clearHighlights cleans up some state. - // The manager's destroy/cleanup logic handles global cleanup. - // For per-element cleanup, we might want to add methods to ContentHighlighter if needed, - // but currently it manages its own maps. - this.options.highlighter.clearHighlights(this.element); - } - - highlight(corrections: ProofreadCorrection[]): void { - this.options.highlighter.highlight(this.element, corrections); - } - - clearHighlights(): void { - this.options.highlighter.clearHighlights(this.element); - } - - clearSelection(): void { - this.options.highlighter.clearSelection(); - } - - updatePreferences(_prefs: { - colorPalette?: IssueColorPalette; - underlineStyle?: UnderlineStyle; - autofixOnDoubleClick?: boolean; - }): void { - // ContentHighlighter manages these globally for now. - // If we needed per-element preferences for direct highlighting, we'd pass them here. - // But ContentHighlighter is a singleton-like service in the manager. - } - - dispose(): void { - this.detach(); - } -} diff --git a/src/content/proofreading-manager.spec.ts b/src/content/proofreading-manager.spec.ts index 201d13e..245ff55 100644 --- a/src/content/proofreading-manager.spec.ts +++ b/src/content/proofreading-manager.spec.ts @@ -79,16 +79,6 @@ vi.mock('./services/content-proofreading-service.ts', () => ({ }, })); -vi.mock('./components/content-highlighter.ts', () => ({ - ContentHighlighter: class { - clearSelection = vi.fn(); - destroy = vi.fn(); - setCorrectionColors = vi.fn(); - previewCorrection = vi.fn(); - clearPreview = vi.fn(); - }, -})); - vi.mock('./handlers/mirror-target-handler.ts', () => ({ MirrorTargetHandler: class { attach = vi.fn(); @@ -99,11 +89,15 @@ vi.mock('./handlers/mirror-target-handler.ts', () => ({ }, })); -vi.mock('./handlers/direct-target-handler.ts', () => ({ - DirectTargetHandler: class { +vi.mock('./handlers/contenteditable-target-handler.ts', () => ({ + ContentEditableTargetHandler: class { attach = vi.fn(); dispose = vi.fn(); + clearHighlights = vi.fn(); clearSelection = vi.fn(); + highlight = vi.fn(); + previewIssue = vi.fn(); + updatePreferences = vi.fn(); }, })); diff --git a/src/content/proofreading-manager.ts b/src/content/proofreading-manager.ts index 7e98c9f..8fe467d 100644 --- a/src/content/proofreading-manager.ts +++ b/src/content/proofreading-manager.ts @@ -1,4 +1,3 @@ -import { ContentHighlighter } from './components/content-highlighter.ts'; import './components/correction-popover.ts'; import { logger } from '../services/logger.ts'; import { getSelectionRangeFromElement } from '../shared/proofreading/controller.ts'; @@ -12,7 +11,7 @@ import { } from '../shared/proofreading/control-events.ts'; import type { TargetHandler } from './handlers/target-handler.ts'; import { MirrorTargetHandler } from './handlers/mirror-target-handler.ts'; -import { DirectTargetHandler } from './handlers/direct-target-handler.ts'; +import { ContentEditableTargetHandler } from './handlers/contenteditable-target-handler.ts'; import { ElementTracker } from './services/element-tracker.ts'; import { PopoverManager } from './services/popover-manager.ts'; import { PreferenceManager } from './services/preference-manager.ts'; @@ -21,7 +20,6 @@ import { ContentProofreadingService } from './services/content-proofreading-serv import { resolveElementKind } from '../shared/messages/issues.ts'; export class ProofreadingManager { - private readonly highlighter = new ContentHighlighter(); private readonly targetHandlers = new Map(); private readonly pageId = createUniqueId('page'); private activeSessionElement: HTMLElement | null = null; @@ -45,7 +43,6 @@ export class ProofreadingManager { }); this.popoverManager = new PopoverManager({ - highlighter: this.highlighter, onCorrectionApplied: (element, correction) => this.handleCorrectionFromPopover(element, correction), onPopoverHide: () => this.handlePopoverHide(), @@ -53,8 +50,7 @@ export class ProofreadingManager { this.preferenceManager = new PreferenceManager({ onCorrectionTypesChanged: () => this.refreshCorrectionsForTrackedElements(), - onCorrectionColorsChanged: (colors, palette) => { - this.highlighter.setCorrectionColors(colors); + onCorrectionColorsChanged: (_colors, palette) => { this.targetHandlers.forEach((handler) => handler.updatePreferences({ colorPalette: palette }) ); @@ -114,7 +110,6 @@ export class ProofreadingManager { this.preferenceManager.destroy(); this.elementTracker.destroy(); this.popoverManager.destroy(); - this.highlighter.destroy(); this.targetHandlers.forEach((handler) => handler.dispose()); this.targetHandlers.clear(); @@ -191,33 +186,20 @@ export class ProofreadingManager { const element = this.elementTracker.getElementById(elementId); if (!element) { logger.warn({ elementId, issueId }, 'Issue preview requested for unknown element'); - this.highlighter.clearPreview(); return; } const handler = this.targetHandlers.get(element); - if (handler instanceof MirrorTargetHandler) { - if (!active) { - handler.previewIssue(null); - return; - } - handler.previewIssue(issueId); - return; - } - if (!active) { - this.highlighter.clearPreview(); + if (handler instanceof MirrorTargetHandler) { + handler.previewIssue(active ? issueId : null); return; } - const correction = this.issueManager.getCorrection(element, issueId); - if (!correction) { - logger.warn({ elementId, issueId }, 'Missing correction for requested issue preview'); - this.highlighter.clearPreview(); + if (handler instanceof ContentEditableTargetHandler) { + handler.previewIssue(active ? issueId : null); return; } - - this.highlighter.previewCorrection(element, correction); } private applyAllIssuesForElement(element: HTMLElement): boolean { @@ -446,15 +428,47 @@ export class ProofreadingManager { initialAutofixOnDoubleClick: this.preferenceManager.isAutofixOnDoubleClickEnabled(), }); } else { - handler = new DirectTargetHandler(element, { - highlighter: this.highlighter, - onCorrectionApplied: (updatedCorrections) => { - this.handleCorrectionsChange(element, updatedCorrections); + handler = new ContentEditableTargetHandler(element, { + onUnderlineClick: (issueId, pageRect, anchorNode) => { + this.activeSessionElement = element; + const correction = this.issueManager.getCorrection(element, issueId); + if (!correction) { + return; + } + const anchorX = pageRect.left + pageRect.width / 2; + const anchorY = pageRect.top + pageRect.height; + const positionResolver = anchorNode + ? () => { + if (!anchorNode.isConnected) { + return null; + } + const rect = anchorNode.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height, + }; + } + : undefined; + this.showPopoverForCorrection(element, correction, anchorX, anchorY, positionResolver); }, - onApplyCorrection: (correction) => { + onUnderlineDoubleClick: (issueId) => { + const correction = this.issueManager.getCorrection(element, issueId); + if (!correction) { + return; + } this.proofreadingService.applyCorrection(element, correction); this.issueManager.scheduleIssuesUpdate(); }, + onInvalidateIssues: () => { + if (!this.proofreadingService.isRestoringFromHistory(element)) { + const handler = this.targetHandlers.get(element); + handler?.clearHighlights(); + this.clearElementState(element, { silent: true }); + } + }, + initialPalette: this.preferenceManager.buildIssuePalette(), + initialUnderlineStyle: this.preferenceManager.getUnderlineStyle(), + initialAutofixOnDoubleClick: this.preferenceManager.isAutofixOnDoubleClickEnabled(), }); } @@ -529,7 +543,6 @@ export class ProofreadingManager { } private handlePopoverHide(): void { - this.highlighter.clearSelection(); if (this.activeSessionElement) { const handler = this.targetHandlers.get(this.activeSessionElement); handler?.clearSelection(); diff --git a/src/content/services/popover-manager.spec.ts b/src/content/services/popover-manager.spec.ts index a8c0028..d3b3f2c 100644 --- a/src/content/services/popover-manager.spec.ts +++ b/src/content/services/popover-manager.spec.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { PopoverManager } from './popover-manager.ts'; -import type { ContentHighlighter } from '../components/content-highlighter.ts'; import type { ProofreadCorrection } from '../../shared/types.ts'; const mockPopover = { @@ -31,13 +30,6 @@ vi.mock('../components/correction-popover.ts', () => ({ }, })); -function createMockHighlighter(): ContentHighlighter { - return { - setPopover: vi.fn(), - clearSelection: vi.fn(), - } as unknown as ContentHighlighter; -} - function createMockElement(): HTMLElement { return {} as HTMLElement; } @@ -52,18 +44,15 @@ function createMockCorrection(): ProofreadCorrection { describe('PopoverManager', () => { let manager: PopoverManager; - let highlighter: ContentHighlighter; let onCorrectionApplied: (element: HTMLElement, correction: ProofreadCorrection) => void; let onPopoverHide: () => void; beforeEach(() => { vi.clearAllMocks(); - highlighter = createMockHighlighter(); onCorrectionApplied = vi.fn() as any; onPopoverHide = vi.fn() as any; manager = new PopoverManager({ - highlighter, onCorrectionApplied, onPopoverHide, }); @@ -183,7 +172,6 @@ describe('PopoverManager', () => { expect(popover.remove).toHaveBeenCalled(); expect((manager as any).popover).toBeNull(); - expect(highlighter.setPopover).toHaveBeenCalledWith(null); }); it('should not throw if popover does not exist', () => { diff --git a/src/content/services/popover-manager.ts b/src/content/services/popover-manager.ts index 98e9be9..bef6479 100644 --- a/src/content/services/popover-manager.ts +++ b/src/content/services/popover-manager.ts @@ -1,9 +1,7 @@ import type { CorrectionPopover } from '../components/correction-popover.ts'; -import type { ContentHighlighter } from '../components/content-highlighter.ts'; import type { ProofreadCorrection } from '../../shared/types.ts'; export interface PopoverManagerOptions { - highlighter: ContentHighlighter; onCorrectionApplied: (element: HTMLElement, correction: ProofreadCorrection) => void; onPopoverHide: () => void; } @@ -71,8 +69,6 @@ export class PopoverManager { } this.popover = popover; - this.options.highlighter.setPopover(this.popover); - this.cleanupHandler(this.popoverHideCleanup); if (!this.popover) { return; @@ -92,7 +88,6 @@ export class PopoverManager { return; } - this.options.highlighter.setPopover(null); this.cleanupHandler(this.popoverHideCleanup); this.popoverHideCleanup = null; this.popover.remove(); diff --git a/src/options/main.ts b/src/options/main.ts index 4cfb1d9..b21294d 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -11,8 +11,9 @@ import { setStorageValue, } from '../shared/utils/storage.ts'; import { STORAGE_KEYS, STORAGE_DEFAULTS } from '../shared/constants.ts'; -import { ContentHighlighter } from '../content/components/content-highlighter.ts'; +import '../content/components/correction-popover.ts'; import type { CorrectionPopover } from '../content/components/correction-popover.ts'; +import { ContentEditableTargetHandler } from '../content/handlers/contenteditable-target-handler.ts'; import { createUniqueId } from '../content/utils.ts'; import { createProofreader, @@ -916,16 +917,58 @@ async function setupLiveTestArea( let activeProofreadingCount = 0; const issueLookup = new Map(); - const highlighter = new ContentHighlighter(); const popover = document.createElement('proofly-correction-popover') as CorrectionPopover; document.body.appendChild(popover); - highlighter.setPopover(popover); + let enabledTypes = new Set(initialEnabledTypes); let colorConfig = structuredClone(initialColorConfig); let colorThemes = buildCorrectionColorThemes(colorConfig); - setActiveCorrectionColors(colorConfig); - highlighter.setCorrectionColors(colorThemes); + + const handler = new ContentEditableTargetHandler(editor as HTMLElement, { + onUnderlineClick: (issueId, pageRect, anchorNode) => { + const correction = issueLookup.get(issueId); + if (!correction) { + return; + } + const text = editor.textContent || ''; + const issueText = text.slice( + Math.max(0, correction.startIndex), + Math.min(text.length, correction.endIndex) + ); + popover.setCorrection(correction, issueText, (applied) => { + controller.applyCorrection(editor as HTMLElement, applied); + }); + const x = pageRect.left + pageRect.width / 2; + const y = pageRect.top + pageRect.height; + const positionResolver = anchorNode + ? () => { + if (!anchorNode.isConnected) { + return null; + } + const rect = anchorNode.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height }; + } + : undefined; + popover.show(x, y, { anchorElement: editor as HTMLElement, positionResolver }); + }, + onUnderlineDoubleClick: (issueId) => { + const correction = issueLookup.get(issueId); + if (!correction) { + return; + } + controller.applyCorrection(editor as HTMLElement, correction); + }, + onInvalidateIssues: () => { + if (options.isAutoCorrectEnabled()) { + controller.scheduleProofread(editor as HTMLElement); + } + }, + initialPalette: colorThemes, + initialUnderlineStyle: 'solid', + initialAutofixOnDoubleClick: false, + }); + handler.attach(); let proofreaderService: ReturnType | null = null; @@ -964,19 +1007,18 @@ async function setupLiveTestArea( const emitIssuesUpdate = (corrections: ProofreadCorrection[]) => { const text = editor.textContent || ''; - // Update issue lookup map issueLookup.clear(); corrections .filter((correction) => correction.endIndex > correction.startIndex) .forEach((correction, index) => { - const issueId = `${correction.startIndex}:${correction.endIndex}:${correction.type ?? 'unknown'}:${index}`; + const issueId = `${correction.startIndex}:${correction.endIndex}:${index}`; issueLookup.set(issueId, correction); }); const issues = corrections .filter((correction) => correction.endIndex > correction.startIndex) .map((correction, index) => { - const issueId = `${correction.startIndex}:${correction.endIndex}:${correction.type ?? 'unknown'}:${index}`; + const issueId = `${correction.startIndex}:${correction.endIndex}:${index}`; const originalText = text.slice( Math.max(0, Math.min(correction.startIndex, text.length)), Math.max(0, Math.min(correction.endIndex, text.length)) @@ -1067,10 +1109,11 @@ async function setupLiveTestArea( element: editor, hooks: { highlight: (corrections) => { - highlighter.highlight(editor, corrections); + handler.highlight(corrections); + emitIssuesUpdate(corrections); }, clearHighlights: () => { - highlighter.clearHighlights(editor); + handler.clearHighlights(); }, onCorrectionsChange: (corrections) => { emitIssuesUpdate(corrections); @@ -1078,16 +1121,6 @@ async function setupLiveTestArea( }, }); - highlighter.setApplyCorrectionCallback(editor, (_target, correction) => { - controller.applyCorrection(editor, correction); - }); - - editor.addEventListener('input', () => { - if (options.isAutoCorrectEnabled()) { - controller.scheduleProofread(editor); - } - }); - editor.addEventListener('focus', () => { if (options.isAutoCorrectEnabled()) { void controller.proofread(editor); @@ -1110,16 +1143,7 @@ async function setupLiveTestArea( const updateEnabledTypes = (types: CorrectionTypeKey[]) => { enabledTypes = new Set(types); - - if ('highlights' in CSS) { - for (const type of ALL_CORRECTION_TYPES) { - if (!enabledTypes.has(type)) { - const highlight = (CSS.highlights as any).get(type); - highlight?.clear?.(); - } - } - } - + handler.clearHighlights(); void refreshProofreading(); }; @@ -1127,7 +1151,7 @@ async function setupLiveTestArea( colorConfig = structuredClone(config); colorThemes = buildCorrectionColorThemes(colorConfig); setActiveCorrectionColors(colorConfig); - highlighter.setCorrectionColors(colorThemes); + handler.updatePreferences({ colorPalette: colorThemes }); }; onStorageChange(STORAGE_KEYS.ENABLED_CORRECTION_TYPES, (newValue) => { @@ -1142,13 +1166,7 @@ async function setupLiveTestArea( const clearHighlights = () => { controller.resetElement(editor); - - if ('highlights' in CSS) { - for (const type of ALL_CORRECTION_TYPES) { - const highlight = (CSS.highlights as any).get(type); - highlight?.clear?.(); - } - } + handler.clearHighlights(); }; const applyIssue = (issueId: string) => { @@ -1197,18 +1215,7 @@ async function setupLiveTestArea( }; const previewIssue = (issueId: string, active: boolean) => { - if (!active) { - highlighter.clearPreview(); - return; - } - - const correction = issueLookup.get(issueId); - if (!correction) { - highlighter.clearPreview(); - return; - } - - highlighter.previewCorrection(editor, correction); + handler.previewIssue(active ? issueId : null); }; return { diff --git a/src/options/style.css b/src/options/style.css index 0d7518f..63e7dd3 100644 --- a/src/options/style.css +++ b/src/options/style.css @@ -222,8 +222,6 @@ main { pointer-events: none; } -/* Proofreading highlights are styled dynamically by ContentHighlighter */ - .correction-type-grid { grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); } From 190e55ad200e8b15c5072ca49de99430a0f56ab1 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Tue, 3 Mar 2026 09:50:34 -0500 Subject: [PATCH 6/9] feat: implement contenteditable text extraction and offset resolution * Added a new utility module for handling contenteditable text extraction and offset resolution. * Replaced direct text content access with the new utility functions across multiple components, enhancing consistency in text handling. * Updated the proofreading manager, contenteditable target handler, and options setup to utilize the new extraction logic for improved accuracy in text processing. --- .../contenteditable-target-handler.ts | 38 ++--- src/content/proofreading-manager.ts | 3 +- src/options/main.ts | 7 +- src/shared/proofreading/controller.ts | 3 +- src/shared/utils/clipboard.ts | 47 +----- src/shared/utils/contenteditable-text.ts | 138 ++++++++++++++++++ 6 files changed, 163 insertions(+), 73 deletions(-) create mode 100644 src/shared/utils/contenteditable-text.ts diff --git a/src/content/handlers/contenteditable-target-handler.ts b/src/content/handlers/contenteditable-target-handler.ts index 0fce9f2..44a8a78 100644 --- a/src/content/handlers/contenteditable-target-handler.ts +++ b/src/content/handlers/contenteditable-target-handler.ts @@ -7,6 +7,10 @@ import { getCorrectionTypeColor, type CorrectionTypeKey, } from '../../shared/utils/correction-types.ts'; +import { + extractContentEditableText, + resolveContentEditableOffset, +} from '../../shared/utils/contenteditable-text.ts'; interface ContentEditableHandlerOptions { onUnderlineClick: (issueId: string, pageRect: DOMRect, anchorNode: HTMLElement) => void; @@ -115,7 +119,7 @@ export class ContentEditableTargetHandler implements TargetHandler { } highlight(corrections: ProofreadCorrection[]): void { - const elementText = this.element.textContent || ''; + const elementText = extractContentEditableText(this.element); this.issues = mapCorrectionsToIssues(corrections, elementText); if (this.issues.length > 0) { @@ -479,39 +483,17 @@ function getContentEditableRects( startIndex: number, endIndex: number ): DOMRect[] { - const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT); - let offset = 0; - let startNode: Text | null = null; - let startLocal = 0; - let endNode: Text | null = null; - let endLocal = 0; - let node: Text | null; - - while ((node = walker.nextNode() as Text | null)) { - const len = node.length; - - if (!startNode && offset + len > startIndex) { - startNode = node; - startLocal = startIndex - offset; - } - - if (offset + len >= endIndex) { - endNode = node; - endLocal = endIndex - offset; - break; - } - - offset += len; - } + const start = resolveContentEditableOffset(field, startIndex); + const end = resolveContentEditableOffset(field, endIndex); - if (!startNode || !endNode) { + if (!start || !end) { return []; } try { const range = document.createRange(); - range.setStart(startNode, Math.max(0, startLocal)); - range.setEnd(endNode, Math.min(endNode.length, endLocal)); + range.setStart(start.node, Math.max(0, start.offset)); + range.setEnd(end.node, Math.min(end.node.length, end.offset)); return Array.from(range.getClientRects()); } catch { return []; diff --git a/src/content/proofreading-manager.ts b/src/content/proofreading-manager.ts index 8fe467d..32a70a1 100644 --- a/src/content/proofreading-manager.ts +++ b/src/content/proofreading-manager.ts @@ -18,6 +18,7 @@ import { PreferenceManager } from './services/preference-manager.ts'; import { IssueManager } from './services/issue-manager.ts'; import { ContentProofreadingService } from './services/content-proofreading-service.ts'; import { resolveElementKind } from '../shared/messages/issues.ts'; +import { extractContentEditableText } from '../shared/utils/contenteditable-text.ts'; export class ProofreadingManager { private readonly targetHandlers = new Map(); @@ -635,7 +636,7 @@ export class ProofreadingManager { if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { return element.value; } - return element.textContent || ''; + return extractContentEditableText(element); } private reportIgnoredElement(element: HTMLElement, reason: ProofreadLifecycleReason): void { diff --git a/src/options/main.ts b/src/options/main.ts index b21294d..a5f90ba 100644 --- a/src/options/main.ts +++ b/src/options/main.ts @@ -40,6 +40,7 @@ import type { CorrectionTypeKey, } from '../shared/utils/correction-types.ts'; import { isMacOS } from '../shared/utils/platform.ts'; +import { extractContentEditableText } from '../shared/utils/contenteditable-text.ts'; import './style.css'; import { logger } from '../services/logger.ts'; import { ensureProofreaderModelReady } from '../services/model-checker.ts'; @@ -931,7 +932,7 @@ async function setupLiveTestArea( if (!correction) { return; } - const text = editor.textContent || ''; + const text = extractContentEditableText(editor as HTMLElement); const issueText = text.slice( Math.max(0, correction.startIndex), Math.min(text.length, correction.endIndex) @@ -1005,7 +1006,7 @@ async function setupLiveTestArea( }; const emitIssuesUpdate = (corrections: ProofreadCorrection[]) => { - const text = editor.textContent || ''; + const text = extractContentEditableText(editor as HTMLElement); issueLookup.clear(); corrections @@ -1095,7 +1096,7 @@ async function setupLiveTestArea( }); }, debounceMs: 1000, - getElementText: (element) => element.textContent || '', + getElementText: (element) => extractContentEditableText(element), onLifecycleEvent: (event) => { if (event.status === 'start') { reportProofreaderBusy(true); diff --git a/src/shared/proofreading/controller.ts b/src/shared/proofreading/controller.ts index f2a7982..1c0ca2b 100644 --- a/src/shared/proofreading/controller.ts +++ b/src/shared/proofreading/controller.ts @@ -5,6 +5,7 @@ import { createUniqueId } from '../../content/utils.ts'; import type { ProofreadCorrection, ProofreadResult } from '../types.ts'; import type { ProofreadingTarget, ProofreadingTargetHooks } from './types.ts'; import type { ProofreadLifecycleReason, ProofreadLifecycleStatus } from './control-events.ts'; +import { extractContentEditableText } from '../utils/contenteditable-text.ts'; export interface ProofreadLifecycleInternalEvent { status: ProofreadLifecycleStatus; @@ -69,7 +70,7 @@ const defaultGetElementText = (element: HTMLElement): string => { if (tagName === 'textarea' || tagName === 'input') { return (element as HTMLTextAreaElement | HTMLInputElement).value; } - return element.textContent || ''; + return extractContentEditableText(element); }; const isSameCorrection = (a: ProofreadCorrection, b: ProofreadCorrection): boolean => diff --git a/src/shared/utils/clipboard.ts b/src/shared/utils/clipboard.ts index 571933b..3f5141d 100644 --- a/src/shared/utils/clipboard.ts +++ b/src/shared/utils/clipboard.ts @@ -7,6 +7,10 @@ */ import { logger } from '../../services/logger.ts'; +import { + resolveContentEditableOffset, + extractContentEditableText, +} from './contenteditable-text.ts'; /** * Replaces a text range in an editable element, maintaining undo/redo history @@ -96,7 +100,7 @@ export function replaceTextWithUndo( } logger.warn('Unable to resolve range for replacement'); - const text = element.textContent || ''; + const text = extractContentEditableText(element); element.textContent = text.substring(0, startIndex) + replacement + text.substring(endIndex); element.normalize(); element.dispatchEvent(new Event('input', { bubbles: true })); @@ -118,8 +122,8 @@ const createRangeForOffsets = ( startIndex: number, endIndex: number ): Range | null => { - const start = resolveTextPosition(element, startIndex); - const end = resolveTextPosition(element, endIndex); + const start = resolveContentEditableOffset(element, startIndex); + const end = resolveContentEditableOffset(element, endIndex); if (!start || !end) { return null; @@ -130,40 +134,3 @@ const createRangeForOffsets = ( range.setEnd(end.node, end.offset); return range; }; - -interface TextPosition { - node: Text; - offset: number; -} - -const resolveTextPosition = (element: HTMLElement, index: number): TextPosition | null => { - let remaining = index; - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - let textNode = walker.nextNode() as Text | null; - - while (textNode) { - const length = textNode.textContent?.length ?? 0; - if (remaining <= length) { - return { node: textNode, offset: remaining }; - } - remaining -= length; - textNode = walker.nextNode() as Text | null; - } - - const lastText = getLastTextNode(element); - if (!lastText) { - return null; - } - - return { node: lastText, offset: lastText.textContent?.length ?? 0 }; -}; - -const getLastTextNode = (element: HTMLElement): Text | null => { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - let last: Text | null = null; - let current: Text | null; - while ((current = walker.nextNode() as Text | null)) { - last = current; - } - return last; -}; diff --git a/src/shared/utils/contenteditable-text.ts b/src/shared/utils/contenteditable-text.ts new file mode 100644 index 0000000..4e71893 --- /dev/null +++ b/src/shared/utils/contenteditable-text.ts @@ -0,0 +1,138 @@ +const BLOCK_TAGS = new Set([ + 'ADDRESS', + 'ARTICLE', + 'ASIDE', + 'BLOCKQUOTE', + 'DD', + 'DETAILS', + 'DIALOG', + 'DIV', + 'DL', + 'DT', + 'FIELDSET', + 'FIGCAPTION', + 'FIGURE', + 'FOOTER', + 'FORM', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEADER', + 'HGROUP', + 'HR', + 'LI', + 'MAIN', + 'NAV', + 'OL', + 'P', + 'PRE', + 'SECTION', + 'TABLE', + 'UL', +]); + +interface TextSegment { + text: string; + node?: Text; +} + +function collectSegments(element: HTMLElement): TextSegment[] { + const segments: TextSegment[] = []; + let needsNewline = false; + + function walk(node: Node): void { + if (node.nodeType === Node.TEXT_NODE) { + const content = node.textContent || ''; + if (content.length === 0) { + return; + } + if (needsNewline && segments.length > 0) { + segments.push({ text: '\n' }); + } + needsNewline = false; + segments.push({ text: content, node: node as Text }); + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const el = node as HTMLElement; + + if (el.tagName === 'BR') { + segments.push({ text: '\n' }); + needsNewline = false; + return; + } + + const isBlock = BLOCK_TAGS.has(el.tagName); + if (isBlock) { + needsNewline = true; + } + + for (const child of el.childNodes) { + walk(child); + } + + if (isBlock) { + needsNewline = true; + } + } + + if (!element.childNodes) { + const text = element.textContent || ''; + if (text.length > 0) { + segments.push({ text }); + } + return segments; + } + + for (const child of element.childNodes) { + walk(child); + } + + return segments; +} + +export function extractContentEditableText(element: HTMLElement): string { + return collectSegments(element) + .map((s) => s.text) + .join(''); +} + +export function resolveContentEditableOffset( + element: HTMLElement, + targetOffset: number +): { node: Text; offset: number } | null { + const segments = collectSegments(element); + let pos = 0; + let lastTextNode: Text | null = null; + + for (const seg of segments) { + const segEnd = pos + seg.text.length; + + if (seg.node) { + if (targetOffset >= pos && targetOffset < segEnd) { + return { node: seg.node, offset: targetOffset - pos }; + } + if (targetOffset === segEnd) { + return { node: seg.node, offset: seg.node.length }; + } + lastTextNode = seg.node; + } else if (targetOffset >= pos && targetOffset < segEnd && lastTextNode) { + return { node: lastTextNode, offset: lastTextNode.length }; + } + + pos = segEnd; + } + + if (lastTextNode && targetOffset === pos) { + return { node: lastTextNode, offset: lastTextNode.length }; + } + + return null; +} From b90a5ce618247d0f1ac584a2a69b85bab40bc262 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Tue, 3 Mar 2026 10:05:06 -0500 Subject: [PATCH 7/9] feat: enhance clipboard utility tests and add contenteditable text extraction tests * Added new tests for clipboard utility functions, including mock implementations for better isolation. * Introduced a new test suite for contenteditable text extraction and offset resolution, covering various scenarios for accurate text handling. * Implemented mock classes to simulate DOM elements and improve test reliability. --- src/shared/utils/clipboard.spec.ts | 153 +++++++++- src/shared/utils/contenteditable-text.spec.ts | 266 ++++++++++++++++++ 2 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 src/shared/utils/contenteditable-text.spec.ts diff --git a/src/shared/utils/clipboard.spec.ts b/src/shared/utils/clipboard.spec.ts index a29e031..76b59b2 100644 --- a/src/shared/utils/clipboard.spec.ts +++ b/src/shared/utils/clipboard.spec.ts @@ -10,6 +10,9 @@ vi.mock('../../services/logger.ts', () => ({ }, })); +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + class MockEvent { type: string; bubbles: boolean; @@ -36,6 +39,7 @@ class MockHTMLElement { } focus() {} + normalize() {} } class MockTextAreaElement extends MockHTMLElement { @@ -64,12 +68,62 @@ class MockTextAreaElement extends MockHTMLElement { } } +class FakeText { + nodeType = TEXT_NODE; + textContent: string; + childNodes: never[] = []; + get length() { + return this.textContent.length; + } + constructor(data: string) { + this.textContent = data; + } +} + +class FakeElement { + nodeType = ELEMENT_NODE; + tagName: string; + childNodes: (FakeElement | FakeText)[] = []; + isContentEditable = false; + private listeners = new Map void)[]>(); + + get textContent(): string { + return this.childNodes + .map((c) => c.textContent) + .filter(Boolean) + .join(''); + } + set textContent(value: string) { + this.childNodes = [new FakeText(value)]; + } + + constructor(tag: string, children: (FakeElement | FakeText)[] = []) { + this.tagName = tag.toUpperCase(); + this.childNodes = children; + } + + focus() {} + normalize() {} + + addEventListener(event: string, handler: (e: MockEvent) => void) { + const list = this.listeners.get(event) ?? []; + list.push(handler); + this.listeners.set(event, list); + } + + dispatchEvent(event: MockEvent) { + this.listeners.get(event.type)?.forEach((handler) => handler(event)); + return true; + } +} + const globalAny = globalThis as any; describe('replaceTextWithUndo', () => { beforeEach(() => { vi.clearAllMocks(); globalAny.Event = MockEvent; + globalAny.Node = { TEXT_NODE, ELEMENT_NODE }; globalAny.HTMLElement = MockHTMLElement; globalAny.HTMLTextAreaElement = MockTextAreaElement; globalAny.HTMLInputElement = class extends MockTextAreaElement {}; @@ -78,7 +132,11 @@ describe('replaceTextWithUndo', () => { createTreeWalker: () => ({ nextNode: () => null, }), - createTextNode: (text: string) => ({ textContent: text }), + createTextNode: (text: string) => new FakeText(text), + createRange: () => new MockRange(), + }; + globalAny.window = { + getSelection: () => ({ removeAllRanges: vi.fn(), addRange: vi.fn() }), }; }); @@ -107,4 +165,97 @@ describe('replaceTextWithUndo', () => { const result = replaceTextWithUndo(element as unknown as HTMLElement, 0, 1, 'x'); expect(result).toBe(false); }); + + it('uses block-aware offsets for contenteditable with paragraphs', () => { + const t1 = new FakeText('First.'); + const t2 = new FakeText('Second.'); + const root = new FakeElement('div', [new FakeElement('p', [t1]), new FakeElement('p', [t2])]); + root.isContentEditable = true; + + // Virtual text: "First.\nSecond." (length 14) + // "Second" starts at index 7, ends at 13 + // Replace "Second" (index 7..13) with "2nd" + const rangeOps = mockRangeCapture(); + const result = replaceTextWithUndo(root as unknown as HTMLElement, 7, 13, '2nd'); + + expect(result).toBe(true); + expect(rangeOps.setStartArgs).toEqual([t2, 0]); + expect(rangeOps.setEndArgs).toEqual([t2, 6]); + expect(logger.info).toHaveBeenCalledWith('Text replaced using Selection/Range API'); + }); + + it('resolves offsets correctly across block boundary for contenteditable', () => { + const t1 = new FakeText('AAA'); + const t2 = new FakeText('BBB'); + const t3 = new FakeText('CCC'); + const root = new FakeElement('div', [ + new FakeElement('li', [t1]), + new FakeElement('li', [t2]), + new FakeElement('li', [t3]), + ]); + root.isContentEditable = true; + + // Virtual text: "AAA\nBBB\nCCC" (length 11) + // Replace "BBB" at index 4..7 + const rangeOps = mockRangeCapture(); + replaceTextWithUndo(root as unknown as HTMLElement, 4, 7, 'DDD'); + + expect(rangeOps.setStartArgs).toEqual([t2, 0]); + expect(rangeOps.setEndArgs).toEqual([t2, 3]); + }); + + it('fallback path uses block-aware text extraction', () => { + const element = new MockHTMLElement(); + element.isContentEditable = true; + element.textContent = 'AB\nCD'; + const result = replaceTextWithUndo(element as unknown as HTMLElement, 3, 5, 'EF'); + expect(result).toBe(false); + expect(element.textContent).toBe('AB\nEF'); + }); }); + +class MockRange { + startNode: unknown = null; + startOffset = 0; + endNode: unknown = null; + endOffset = 0; + + setStart(node: unknown, offset: number) { + this.startNode = node; + this.startOffset = offset; + } + + setEnd(node: unknown, offset: number) { + this.endNode = node; + this.endOffset = offset; + } + + deleteContents() {} + insertNode() {} + setStartAfter() {} + collapse() {} +} + +function mockRangeCapture() { + const ops = { + setStartArgs: null as [unknown, number] | null, + setEndArgs: null as [unknown, number] | null, + }; + + globalAny.document.createRange = () => { + const range = new MockRange(); + const origSetStart = range.setStart.bind(range); + const origSetEnd = range.setEnd.bind(range); + range.setStart = (node: unknown, offset: number) => { + ops.setStartArgs = [node, offset]; + origSetStart(node, offset); + }; + range.setEnd = (node: unknown, offset: number) => { + ops.setEndArgs = [node, offset]; + origSetEnd(node, offset); + }; + return range; + }; + + return ops; +} diff --git a/src/shared/utils/contenteditable-text.spec.ts b/src/shared/utils/contenteditable-text.spec.ts new file mode 100644 index 0000000..4833e9e --- /dev/null +++ b/src/shared/utils/contenteditable-text.spec.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + extractContentEditableText, + resolveContentEditableOffset, +} from './contenteditable-text.ts'; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + nodeType = TEXT_NODE; + textContent: string; + childNodes: never[] = []; + get length() { + return this.textContent.length; + } + constructor(data: string) { + this.textContent = data; + } +} + +class FakeElement { + nodeType = ELEMENT_NODE; + tagName: string; + childNodes: (FakeElement | FakeText)[] = []; + get textContent(): string { + return this.childNodes + .map((c) => c.textContent) + .filter(Boolean) + .join(''); + } + constructor(tag: string, children: (FakeElement | FakeText)[] = []) { + this.tagName = tag.toUpperCase(); + this.childNodes = children; + } +} + +function text(data: string): FakeText { + return new FakeText(data); +} + +function el(tag: string, ...children: (FakeElement | FakeText)[]): FakeElement { + return new FakeElement(tag, children); +} + +const asHTML = (node: FakeElement) => node as unknown as HTMLElement; + +beforeEach(() => { + const g = globalThis as any; + g.Node = { TEXT_NODE, ELEMENT_NODE }; +}); + +describe('extractContentEditableText', () => { + it('extracts plain text from a single text node', () => { + const root = el('div', text('Hello world')); + expect(extractContentEditableText(asHTML(root))).toBe('Hello world'); + }); + + it('concatenates adjacent text nodes without separator', () => { + const root = el('div', text('Hello '), text('world')); + expect(extractContentEditableText(asHTML(root))).toBe('Hello world'); + }); + + it('inserts newline between sibling block elements', () => { + const root = el('div', el('p', text('First.')), el('p', text('Second.'))); + expect(extractContentEditableText(asHTML(root))).toBe('First.\nSecond.'); + }); + + it('inserts newline between list items', () => { + const root = el('ul', el('li', text('item one.')), el('li', text('item two'))); + expect(extractContentEditableText(asHTML(root))).toBe('item one.\nitem two'); + }); + + it('handles BR elements as newlines', () => { + const root = el('div', text('line one'), el('br'), text('line two')); + expect(extractContentEditableText(asHTML(root))).toBe('line one\nline two'); + }); + + it('does not insert extra separators for inline elements', () => { + const root = el('p', text('Hello '), el('strong', text('bold')), text(' world')); + expect(extractContentEditableText(asHTML(root))).toBe('Hello bold world'); + }); + + it('handles nested block elements with inline children', () => { + const root = el( + 'div', + el('p', text('Hello '), el('em', text('italic'))), + el('p', text('Next line')) + ); + expect(extractContentEditableText(asHTML(root))).toBe('Hello italic\nNext line'); + }); + + it('does not add leading newline for the first block child', () => { + const root = el('div', el('p', text('Only paragraph'))); + expect(extractContentEditableText(asHTML(root))).toBe('Only paragraph'); + }); + + it('inserts single newline between consecutive blocks', () => { + const root = el('div', el('div', text('A')), el('div', text('B')), el('div', text('C'))); + expect(extractContentEditableText(asHTML(root))).toBe('A\nB\nC'); + }); + + it('returns empty string for an element with no text nodes', () => { + const root = el('div', el('p'), el('p')); + expect(extractContentEditableText(asHTML(root))).toBe(''); + }); + + it('handles deeply nested block elements', () => { + const root = el('div', el('blockquote', el('p', text('Quoted'))), el('p', text('Normal'))); + expect(extractContentEditableText(asHTML(root))).toBe('Quoted\nNormal'); + }); + + it('falls back to textContent when childNodes is missing', () => { + const mock = { textContent: 'fallback text' } as unknown as HTMLElement; + expect(extractContentEditableText(mock)).toBe('fallback text'); + }); + + it('returns empty string for element without childNodes and empty textContent', () => { + const mock = { textContent: '' } as unknown as HTMLElement; + expect(extractContentEditableText(mock)).toBe(''); + }); +}); + +describe('resolveContentEditableOffset', () => { + it('resolves offset within a single text node', () => { + const t = text('Hello world'); + const root = el('div', t); + const result = resolveContentEditableOffset(asHTML(root), 5); + expect(result).toEqual({ node: t, offset: 5 }); + }); + + it('resolves offset at start of text', () => { + const t = text('Hello'); + const root = el('div', t); + const result = resolveContentEditableOffset(asHTML(root), 0); + expect(result).toEqual({ node: t, offset: 0 }); + }); + + it('resolves offset at exact end of text node', () => { + const t = text('Hello'); + const root = el('div', t); + const result = resolveContentEditableOffset(asHTML(root), 5); + expect(result).toEqual({ node: t, offset: 5 }); + }); + + it('resolves offset in second paragraph after implicit newline', () => { + const t1 = text('ABC'); + const t2 = text('DEF'); + const root = el('div', el('p', t1), el('p', t2)); + // Virtual text: "ABC\nDEF" (length 7) + // Offset 4 → "D" → t2, local offset 0 + const result = resolveContentEditableOffset(asHTML(root), 4); + expect(result).toEqual({ node: t2, offset: 0 }); + }); + + it('resolves offset at end of first paragraph before newline', () => { + const t1 = text('ABC'); + const t2 = text('DEF'); + const root = el('div', el('p', t1), el('p', t2)); + // Virtual text: "ABC\nDEF" + // Offset 3 → end of "ABC" → t1, local offset 3 + const result = resolveContentEditableOffset(asHTML(root), 3); + expect(result).toEqual({ node: t1, offset: 3 }); + }); + + it('resolves offset on implicit newline to end of previous text node', () => { + const t1 = text('ABC'); + const t2 = text('DEF'); + const root = el('div', el('p', t1), el('p', t2)); + // Virtual text: "ABC\nDEF" + // The \n is at index 3, but offset 3 matches end of t1 (handled above). + // Checking that resolving mid-paragraph works: + const result = resolveContentEditableOffset(asHTML(root), 6); + expect(result).toEqual({ node: t2, offset: 2 }); + }); + + it('resolves offset at total text length', () => { + const t1 = text('AB'); + const t2 = text('CD'); + const root = el('div', el('p', t1), el('p', t2)); + // Virtual text: "AB\nCD" (length 5) + const result = resolveContentEditableOffset(asHTML(root), 5); + expect(result).toEqual({ node: t2, offset: 2 }); + }); + + it('returns null for empty element', () => { + const root = el('div'); + const result = resolveContentEditableOffset(asHTML(root), 0); + expect(result).toBeNull(); + }); + + it('returns null when offset exceeds total length', () => { + const t = text('Hi'); + const root = el('div', t); + const result = resolveContentEditableOffset(asHTML(root), 10); + expect(result).toBeNull(); + }); + + it('handles multiple text nodes inside a single block', () => { + const t1 = text('Hello '); + const t2 = text('world'); + const root = el('div', el('p', t1, el('strong', t2))); + // Virtual text: "Hello world" (no newline, inline element) + const result = resolveContentEditableOffset(asHTML(root), 8); + expect(result).toEqual({ node: t2, offset: 2 }); + }); + + it('handles three list items', () => { + const t1 = text('A'); + const t2 = text('B'); + const t3 = text('C'); + const root = el('ul', el('li', t1), el('li', t2), el('li', t3)); + // Virtual text: "A\nB\nC" (length 5) + expect(resolveContentEditableOffset(asHTML(root), 0)).toEqual({ node: t1, offset: 0 }); + expect(resolveContentEditableOffset(asHTML(root), 1)).toEqual({ node: t1, offset: 1 }); + expect(resolveContentEditableOffset(asHTML(root), 2)).toEqual({ node: t2, offset: 0 }); + expect(resolveContentEditableOffset(asHTML(root), 3)).toEqual({ node: t2, offset: 1 }); + expect(resolveContentEditableOffset(asHTML(root), 4)).toEqual({ node: t3, offset: 0 }); + expect(resolveContentEditableOffset(asHTML(root), 5)).toEqual({ node: t3, offset: 1 }); + }); + + it('handles BR elements in offset calculation', () => { + const t1 = text('AB'); + const t2 = text('CD'); + const root = el('div', t1, el('br'), t2); + // Virtual text: "AB\nCD" (length 5) + expect(resolveContentEditableOffset(asHTML(root), 0)).toEqual({ node: t1, offset: 0 }); + expect(resolveContentEditableOffset(asHTML(root), 2)).toEqual({ node: t1, offset: 2 }); + expect(resolveContentEditableOffset(asHTML(root), 3)).toEqual({ node: t2, offset: 0 }); + expect(resolveContentEditableOffset(asHTML(root), 5)).toEqual({ node: t2, offset: 2 }); + }); +}); + +describe('extractContentEditableText and resolveContentEditableOffset consistency', () => { + it('round-trips: each character in extracted text resolves to a valid node', () => { + const t1 = text('Hello.'); + const t2 = text('World!'); + const root = el('ul', el('li', t1), el('li', t2)); + const extracted = extractContentEditableText(asHTML(root)); + expect(extracted).toBe('Hello.\nWorld!'); + + for (let i = 0; i < extracted.length; i++) { + const result = resolveContentEditableOffset(asHTML(root), i); + expect(result).not.toBeNull(); + } + + const endResult = resolveContentEditableOffset(asHTML(root), extracted.length); + expect(endResult).not.toBeNull(); + }); + + it('offsets in second block point to correct text node', () => { + const t1 = text('item one.'); + const t2 = text('item two'); + const root = el('ul', el('li', t1), el('li', t2)); + const extracted = extractContentEditableText(asHTML(root)); + expect(extracted).toBe('item one.\nitem two'); + + const secondItemStart = extracted.indexOf('item two'); + const resolved = resolveContentEditableOffset(asHTML(root), secondItemStart); + expect(resolved).toEqual({ node: t2, offset: 0 }); + + const midSecondItem = secondItemStart + 4; + const resolvedMid = resolveContentEditableOffset(asHTML(root), midSecondItem); + expect(resolvedMid).toEqual({ node: t2, offset: 4 }); + }); +}); From b28ab60360315a4eb9fe2f67a7b0a64267b5e50f Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Tue, 3 Mar 2026 18:29:10 -0500 Subject: [PATCH 8/9] feat: enhance proofreading manager and contenteditable target handler functionality * Introduced debouncing for the onNeedProofread callback in the ContentEditableTargetHandler to optimize performance during rapid input events. * Updated the ProofreadingManager to handle element registration and auto-proofreading checks more effectively. * Added comprehensive tests for the ContentEditableTargetHandler to ensure correct behavior during input handling and proofread triggering. * Refactored existing logic to improve clarity and maintainability in both the proofreading manager and contenteditable handler. --- .../contenteditable-target-handler.spec.ts | 223 ++++++++++++++++++ .../contenteditable-target-handler.ts | 9 + src/content/proofreading-manager.spec.ts | 120 +++++++++- src/content/proofreading-manager.ts | 21 +- 4 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 src/content/handlers/contenteditable-target-handler.spec.ts diff --git a/src/content/handlers/contenteditable-target-handler.spec.ts b/src/content/handlers/contenteditable-target-handler.spec.ts new file mode 100644 index 0000000..10541bd --- /dev/null +++ b/src/content/handlers/contenteditable-target-handler.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ContentEditableTargetHandler } from './contenteditable-target-handler.ts'; +import type { IssueColorPalette } from '../target-session.ts'; + +vi.mock('../sync.ts', () => ({ + createRafScheduler: vi.fn(() => ({ schedule: vi.fn(), cancel: vi.fn() })), +})); + +vi.mock('../renderer.ts', () => ({ + UnderlineRenderer: class { + clear = vi.fn(); + render = vi.fn(); + }, +})); + +vi.mock('../../shared/utils/contenteditable-text.ts', () => ({ + extractContentEditableText: vi.fn(() => 'test text'), + resolveContentEditableOffset: vi.fn(), +})); + +vi.mock('../../shared/utils/correction-types.ts', () => ({ + getCorrectionTypeColor: vi.fn(() => ({ label: 'Spelling', color: '#dc2626' })), +})); + +function createFakeDomElement() { + const listeners: Record void)[]> = {}; + return { + addEventListener: vi.fn((type: string, fn: (...args: unknown[]) => void) => { + listeners[type] = listeners[type] || []; + listeners[type].push(fn); + }), + removeEventListener: vi.fn((type: string, fn: (...args: unknown[]) => void) => { + listeners[type] = (listeners[type] || []).filter((f) => f !== fn); + }), + fire(type: string) { + (listeners[type] || []).forEach((fn) => fn()); + }, + getBoundingClientRect: vi.fn(() => new DOMRect(0, 0, 200, 100)), + parentElement: { insertBefore: vi.fn(), appendChild: vi.fn() }, + nextSibling: null, + closest: vi.fn(() => null), + ownerDocument: { body: { appendChild: vi.fn() } }, + clientWidth: 200, + clientHeight: 100, + isContentEditable: true, + childNodes: [], + textContent: 'test text', + }; +} + +function createStubElement() { + return { + setAttribute: vi.fn(), + style: {} as Record, + attachShadow: vi.fn(() => ({ append: vi.fn() })), + remove: vi.fn(), + id: '', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + classList: { contains: vi.fn(() => false) }, + dataset: {} as Record, + appendChild: vi.fn(), + }; +} + +describe('ContentEditableTargetHandler', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('document', { + createElement: vi.fn(() => createStubElement()), + createRange: vi.fn(() => ({ + setStart: vi.fn(), + setEnd: vi.fn(), + getClientRects: vi.fn(() => []), + })), + body: { appendChild: vi.fn() }, + }); + vi.stubGlobal( + 'getComputedStyle', + vi.fn(() => ({ + zIndex: '1', + overflow: 'visible', + overflowY: 'visible', + lineHeight: '20', + fontSize: '16', + })) + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe = vi.fn(); + disconnect = vi.fn(); + } + ); + vi.stubGlobal( + 'DOMRect', + class DOMRect { + constructor( + public x = 0, + public y = 0, + public width = 0, + public height = 0 + ) {} + get left() { + return this.x; + } + get top() { + return this.y; + } + get right() { + return this.x + this.width; + } + get bottom() { + return this.y + this.height; + } + } + ); + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + describe('input handling', () => { + it('should call onInvalidateIssues immediately on input', () => { + const element = createFakeDomElement(); + const onInvalidateIssues = vi.fn(); + const handler = new ContentEditableTargetHandler(element as unknown as HTMLElement, { + onUnderlineClick: vi.fn(), + onUnderlineDoubleClick: vi.fn(), + onInvalidateIssues, + onNeedProofread: vi.fn(), + initialPalette: {} as IssueColorPalette, + initialUnderlineStyle: 'wavy', + initialAutofixOnDoubleClick: false, + }); + + handler.attach(); + element.fire('input'); + + expect(onInvalidateIssues).toHaveBeenCalledTimes(1); + }); + + it('should call onNeedProofread after debounce on input', () => { + const element = createFakeDomElement(); + const onNeedProofread = vi.fn(); + const handler = new ContentEditableTargetHandler(element as unknown as HTMLElement, { + onUnderlineClick: vi.fn(), + onUnderlineDoubleClick: vi.fn(), + onInvalidateIssues: vi.fn(), + onNeedProofread, + initialPalette: {} as IssueColorPalette, + initialUnderlineStyle: 'wavy', + initialAutofixOnDoubleClick: false, + }); + + handler.attach(); + element.fire('input'); + + expect(onNeedProofread).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + + expect(onNeedProofread).toHaveBeenCalledTimes(1); + }); + + it('should debounce multiple rapid inputs into single onNeedProofread call', () => { + const element = createFakeDomElement(); + const onInvalidateIssues = vi.fn(); + const onNeedProofread = vi.fn(); + const handler = new ContentEditableTargetHandler(element as unknown as HTMLElement, { + onUnderlineClick: vi.fn(), + onUnderlineDoubleClick: vi.fn(), + onInvalidateIssues, + onNeedProofread, + initialPalette: {} as IssueColorPalette, + initialUnderlineStyle: 'wavy', + initialAutofixOnDoubleClick: false, + }); + + handler.attach(); + + element.fire('input'); + vi.advanceTimersByTime(300); + element.fire('input'); + vi.advanceTimersByTime(300); + element.fire('input'); + + expect(onInvalidateIssues).toHaveBeenCalledTimes(3); + expect(onNeedProofread).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + + expect(onNeedProofread).toHaveBeenCalledTimes(1); + }); + + it('should not call onNeedProofread when callback is not provided', () => { + const element = createFakeDomElement(); + const onInvalidateIssues = vi.fn(); + const handler = new ContentEditableTargetHandler(element as unknown as HTMLElement, { + onUnderlineClick: vi.fn(), + onUnderlineDoubleClick: vi.fn(), + onInvalidateIssues, + initialPalette: {} as IssueColorPalette, + initialUnderlineStyle: 'wavy', + initialAutofixOnDoubleClick: false, + }); + + handler.attach(); + element.fire('input'); + + vi.advanceTimersByTime(800); + + expect(onInvalidateIssues).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/content/handlers/contenteditable-target-handler.ts b/src/content/handlers/contenteditable-target-handler.ts index 44a8a78..5f8dd02 100644 --- a/src/content/handlers/contenteditable-target-handler.ts +++ b/src/content/handlers/contenteditable-target-handler.ts @@ -11,11 +11,15 @@ import { extractContentEditableText, resolveContentEditableOffset, } from '../../shared/utils/contenteditable-text.ts'; +import { debounce } from '../../shared/utils/debounce.ts'; + +const PROOFREAD_DEBOUNCE_MS = 800; interface ContentEditableHandlerOptions { onUnderlineClick: (issueId: string, pageRect: DOMRect, anchorNode: HTMLElement) => void; onUnderlineDoubleClick: (issueId: string) => void; onInvalidateIssues: () => void; + onNeedProofread?: () => void; initialPalette: IssueColorPalette; initialUnderlineStyle: UnderlineStyle; initialAutofixOnDoubleClick: boolean; @@ -49,6 +53,7 @@ export class ContentEditableTargetHandler implements TargetHandler { private measuredDescriptors: UnderlineDescriptor[] = []; private resizeObserver: ResizeObserver | null = null; private scrollParent: Element | null = null; + private readonly debouncedNeedProofread: () => void; constructor( public readonly element: HTMLElement, @@ -59,6 +64,9 @@ export class ContentEditableTargetHandler implements TargetHandler { this.colorPalette = options.initialPalette; this.underlineStyle = options.initialUnderlineStyle; this.autofixOnDoubleClick = options.initialAutofixOnDoubleClick; + this.debouncedNeedProofread = debounce(() => { + this.options.onNeedProofread?.(); + }, PROOFREAD_DEBOUNCE_MS); } attach(): void { @@ -183,6 +191,7 @@ export class ContentEditableTargetHandler implements TargetHandler { private readonly handleInput = () => { this.options.onInvalidateIssues(); + this.debouncedNeedProofread(); }; private readonly handleWindowScroll = () => { diff --git a/src/content/proofreading-manager.spec.ts b/src/content/proofreading-manager.spec.ts index 245ff55..35f0590 100644 --- a/src/content/proofreading-manager.spec.ts +++ b/src/content/proofreading-manager.spec.ts @@ -10,6 +10,7 @@ vi.mock('../shared/proofreading/control-events.ts', () => ({ emitProofreadControlEvent: vi.fn(), })); +let lastElementTracker: Record>; vi.mock('./services/element-tracker.ts', () => ({ ElementTracker: class { initialize = vi.fn(); @@ -23,6 +24,9 @@ vi.mock('./services/element-tracker.ts', () => ({ isProofreadTarget = vi.fn(() => true); shouldAutoProofread = vi.fn(() => true); resolveAutoProofreadIgnoreReason = vi.fn(() => 'unsupported-target'); + constructor() { + lastElementTracker = this as unknown as Record>; + } }, })); @@ -64,6 +68,7 @@ vi.mock('./services/issue-manager.ts', () => ({ }, })); +let lastProofreadService: Record>; vi.mock('./services/content-proofreading-service.ts', () => ({ ContentProofreadingService: class { initialize = vi.fn(async () => {}); @@ -76,6 +81,9 @@ vi.mock('./services/content-proofreading-service.ts', () => ({ getCorrections = vi.fn(() => []); isRestoringFromHistory = vi.fn(() => false); cancelPendingProofreads = vi.fn(); + constructor() { + lastProofreadService = this as unknown as Record>; + } }, })); @@ -89,6 +97,11 @@ vi.mock('./handlers/mirror-target-handler.ts', () => ({ }, })); +let lastCEHandlerOptions: { + onInvalidateIssues: () => void; + onNeedProofread?: () => void; + [key: string]: unknown; +} | null = null; vi.mock('./handlers/contenteditable-target-handler.ts', () => ({ ContentEditableTargetHandler: class { attach = vi.fn(); @@ -98,6 +111,9 @@ vi.mock('./handlers/contenteditable-target-handler.ts', () => ({ highlight = vi.fn(); previewIssue = vi.fn(); updatePreferences = vi.fn(); + constructor(_element: unknown, options: typeof lastCEHandlerOptions) { + lastCEHandlerOptions = options; + } }, })); @@ -120,11 +136,19 @@ function createElement(tagName: string, text = ''): HTMLElement { } as unknown as HTMLElement; } +type PrivateManager = { + handleElementFocused: (element: HTMLElement) => void; + handleElementInput: (element: HTMLElement) => void; + handleProofreadLifecycle: (event: ProofreadLifecycleInternalEvent) => void; + reportIgnoredElement: (el: HTMLElement, reason: ProofreadLifecycleReason) => void; +}; + describe('ProofreadingManager', () => { let manager: ProofreadingManager; beforeEach(() => { vi.clearAllMocks(); + lastCEHandlerOptions = null; vi.stubGlobal('document', { addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -162,11 +186,7 @@ describe('ProofreadingManager', () => { it('should enrich lifecycle events with element metadata', () => { const element = createElement('input'); - ( - manager as unknown as { - handleProofreadLifecycle: (event: ProofreadLifecycleInternalEvent) => void; - } - ).handleProofreadLifecycle({ + (manager as unknown as PrivateManager).handleProofreadLifecycle({ status: 'complete', element, executionId: 'exec-123', @@ -189,11 +209,7 @@ describe('ProofreadingManager', () => { it('should report ignored events with reason', () => { const element = createElement('div', 'draft text'); - ( - manager as unknown as { - reportIgnoredElement: (el: HTMLElement, reason: ProofreadLifecycleReason) => void; - } - ).reportIgnoredElement(element, 'unsupported-target'); + (manager as unknown as PrivateManager).reportIgnoredElement(element, 'unsupported-target'); expect(emitProofreadControlEvent).toHaveBeenCalledWith( expect.objectContaining({ @@ -245,4 +261,88 @@ describe('ProofreadingManager', () => { expect(manager).toBeDefined(); }); }); + + describe('contenteditable re-proofreading on input', () => { + it('should call scheduleProofread via handleElementInput when shouldAutoProofread passes', () => { + const element = createElement('div', 'text with erors'); + + (manager as unknown as PrivateManager).handleElementInput(element); + + expect(lastProofreadService.scheduleProofread).toHaveBeenCalledWith(element); + }); + + it('should create ContentEditableTargetHandler with onNeedProofread on focus', () => { + const element = createElement('div', 'text with erors'); + + (manager as unknown as PrivateManager).handleElementFocused(element); + + expect(lastCEHandlerOptions).not.toBeNull(); + expect(lastCEHandlerOptions!.onNeedProofread).toBeDefined(); + expect(typeof lastCEHandlerOptions!.onNeedProofread).toBe('function'); + }); + + it('should trigger proofread when onNeedProofread is called', () => { + const element = createElement('div', 'text with erors'); + + (manager as unknown as PrivateManager).handleElementFocused(element); + lastProofreadService.proofread.mockClear(); + + lastCEHandlerOptions!.onNeedProofread!(); + + expect(lastProofreadService.proofread).toHaveBeenCalledWith(element); + }); + + it('should re-proofread via handleElementInput when element is already registered even if shouldAutoProofread flips', () => { + const element = createElement('div', 'text with erors'); + let callCount = 0; + lastElementTracker.shouldAutoProofread.mockImplementation(() => { + callCount++; + return callCount <= 1; + }); + lastElementTracker.isRegistered.mockReturnValue(false); + + (manager as unknown as PrivateManager).handleElementFocused(element); + + expect(lastProofreadService.proofread).toHaveBeenCalledWith(element); + lastProofreadService.proofread.mockClear(); + lastProofreadService.scheduleProofread.mockClear(); + + lastElementTracker.isRegistered.mockReturnValue(true); + + (manager as unknown as PrivateManager).handleElementInput(element); + + expect(lastProofreadService.scheduleProofread).toHaveBeenCalledWith(element); + }); + + it('should re-proofread via onNeedProofread when element is already registered even if shouldAutoProofread flips', () => { + const element = createElement('div', 'text with erors'); + let callCount = 0; + lastElementTracker.shouldAutoProofread.mockImplementation(() => { + callCount++; + return callCount <= 1; + }); + lastElementTracker.isRegistered.mockReturnValue(false); + + (manager as unknown as PrivateManager).handleElementFocused(element); + + expect(lastProofreadService.proofread).toHaveBeenCalledWith(element); + lastProofreadService.proofread.mockClear(); + + lastElementTracker.isRegistered.mockReturnValue(true); + + lastCEHandlerOptions!.onNeedProofread!(); + + expect(lastProofreadService.proofread).toHaveBeenCalledWith(element); + }); + + it('should still block re-proofread for unregistered elements when shouldAutoProofread fails', () => { + const element = createElement('div', 'text with erors'); + lastElementTracker.shouldAutoProofread.mockReturnValue(false); + lastElementTracker.isRegistered.mockReturnValue(false); + + (manager as unknown as PrivateManager).handleElementInput(element); + + expect(lastProofreadService.scheduleProofread).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/content/proofreading-manager.ts b/src/content/proofreading-manager.ts index 32a70a1..ecb21c0 100644 --- a/src/content/proofreading-manager.ts +++ b/src/content/proofreading-manager.ts @@ -296,10 +296,12 @@ export class ProofreadingManager { if (!this.preferenceManager.isAutoCorrectEnabled()) { return; } - if (!this.elementTracker.shouldAutoProofread(element)) { - const reason = this.elementTracker.resolveAutoProofreadIgnoreReason(element); - this.reportIgnoredElement(element, reason); - return; + if (!this.elementTracker.isRegistered(element)) { + if (!this.elementTracker.shouldAutoProofread(element)) { + const reason = this.elementTracker.resolveAutoProofreadIgnoreReason(element); + this.reportIgnoredElement(element, reason); + return; + } } this.issueManager.clearMessage(element); this.registerElement(element); @@ -380,11 +382,6 @@ export class ProofreadingManager { if (!this.preferenceManager.isAutoCorrectEnabled()) { return; } - if (!this.elementTracker.shouldAutoProofread(element)) { - const reason = this.elementTracker.resolveAutoProofreadIgnoreReason(element); - this.reportIgnoredElement(element, reason); - return; - } void this.proofreadingService.proofread(element); }, onUnderlineClick: (issueId, pageRect, anchorNode) => { @@ -467,6 +464,12 @@ export class ProofreadingManager { this.clearElementState(element, { silent: true }); } }, + onNeedProofread: () => { + if (!this.preferenceManager.isAutoCorrectEnabled()) { + return; + } + void this.proofreadingService.proofread(element); + }, initialPalette: this.preferenceManager.buildIssuePalette(), initialUnderlineStyle: this.preferenceManager.getUnderlineStyle(), initialAutofixOnDoubleClick: this.preferenceManager.isAutofixOnDoubleClickEnabled(), From 1b8fa55a0e11d42b40aac398bbbc2d50752246f7 Mon Sep 17 00:00:00 2001 From: Nathan Redblur Date: Thu, 5 Mar 2026 12:03:04 -0500 Subject: [PATCH 9/9] feat: improve proofreading functionality with incremental selection and state management * Updated the ProofreadingManager to replace highlight clearing with selection clearing and introduced a new method for soft invalidation of elements. * Enhanced the ContentEditableTargetHandler to trigger rendering updates when issues are present during input handling. * Added a new utility module for computing incremental text selections and shifting corrections based on text changes. * Introduced comprehensive tests for the new text-diff utilities to ensure accurate detection of text changes and correction adjustments. --- .../contenteditable-target-handler.ts | 5 + src/content/proofreading-manager.ts | 15 ++- src/shared/proofreading/controller.ts | 23 +++- src/shared/utils/text-diff.spec.ts | 116 ++++++++++++++++++ src/shared/utils/text-diff.ts | 87 +++++++++++++ 5 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 src/shared/utils/text-diff.spec.ts create mode 100644 src/shared/utils/text-diff.ts diff --git a/src/content/handlers/contenteditable-target-handler.ts b/src/content/handlers/contenteditable-target-handler.ts index 5f8dd02..de28386 100644 --- a/src/content/handlers/contenteditable-target-handler.ts +++ b/src/content/handlers/contenteditable-target-handler.ts @@ -191,6 +191,11 @@ export class ContentEditableTargetHandler implements TargetHandler { private readonly handleInput = () => { this.options.onInvalidateIssues(); + if (this.issues.length > 0) { + this.needsMeasurement = true; + this.needsRender = true; + this.raf.schedule(); + } this.debouncedNeedProofread(); }; diff --git a/src/content/proofreading-manager.ts b/src/content/proofreading-manager.ts index ecb21c0..3206c20 100644 --- a/src/content/proofreading-manager.ts +++ b/src/content/proofreading-manager.ts @@ -417,8 +417,8 @@ export class ProofreadingManager { onInvalidateIssues: () => { if (!this.proofreadingService.isRestoringFromHistory(element)) { const handler = this.targetHandlers.get(element); - handler?.clearHighlights(); - this.clearElementState(element, { silent: true }); + handler?.clearSelection(); + this.softInvalidateElement(element); } }, initialPalette: this.preferenceManager.buildIssuePalette(), @@ -460,8 +460,8 @@ export class ProofreadingManager { onInvalidateIssues: () => { if (!this.proofreadingService.isRestoringFromHistory(element)) { const handler = this.targetHandlers.get(element); - handler?.clearHighlights(); - this.clearElementState(element, { silent: true }); + handler?.clearSelection(); + this.softInvalidateElement(element); } }, onNeedProofread: () => { @@ -480,6 +480,13 @@ export class ProofreadingManager { this.targetHandlers.set(element, handler); } + private softInvalidateElement(element: HTMLElement): void { + this.popoverManager.hide(); + if (this.activeSessionElement === element) { + this.activeSessionElement = null; + } + } + private clearElementState(element: HTMLElement, options: { silent?: boolean } = {}): void { const hadCorrections = this.issueManager.getCorrections(element).length > 0; diff --git a/src/shared/proofreading/controller.ts b/src/shared/proofreading/controller.ts index 1c0ca2b..8413bbe 100644 --- a/src/shared/proofreading/controller.ts +++ b/src/shared/proofreading/controller.ts @@ -6,6 +6,7 @@ import type { ProofreadCorrection, ProofreadResult } from '../types.ts'; import type { ProofreadingTarget, ProofreadingTargetHooks } from './types.ts'; import type { ProofreadLifecycleReason, ProofreadLifecycleStatus } from './control-events.ts'; import { extractContentEditableText } from '../utils/contenteditable-text.ts'; +import { computeIncrementalSelection, shiftCorrections } from '../utils/text-diff.ts'; export interface ProofreadLifecycleInternalEvent { status: ProofreadLifecycleStatus; @@ -191,12 +192,28 @@ export class ProofreadingController { } const text = this.getElementText(element); - const selectionRange = this.clampSelectionRange(options.selection, text.length); - const hasSelection = selectionRange !== null; - const textLength = hasSelection ? selectionRange.end - selectionRange.start : text.length; + let selectionRange = this.clampSelectionRange(options.selection, text.length); + let hasSelection = selectionRange !== null; const executionId = newExecutionId(); const forced = Boolean(options.force); + if (!forced && !hasSelection && state.lastText.length > 0 && state.corrections.length > 0) { + const incremental = computeIncrementalSelection(state.lastText, text); + if (incremental) { + state.corrections = shiftCorrections( + state.corrections, + incremental.oldDiffEnd, + incremental.delta + ); + state.hooks.highlight(state.corrections); + state.hooks.onCorrectionsChange?.(state.corrections); + selectionRange = incremental.selection; + hasSelection = true; + } + } + + const textLength = hasSelection ? selectionRange!.end - selectionRange!.start : text.length; + this.reportLifecycle?.({ status: 'queued', element, diff --git a/src/shared/utils/text-diff.spec.ts b/src/shared/utils/text-diff.spec.ts new file mode 100644 index 0000000..57005cd --- /dev/null +++ b/src/shared/utils/text-diff.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { computeIncrementalSelection, shiftCorrections } from './text-diff.ts'; +import type { ProofreadCorrection } from '../types.ts'; + +describe('computeIncrementalSelection', () => { + it('returns null for identical texts', () => { + expect(computeIncrementalSelection('hello', 'hello')).toBeNull(); + }); + + it('returns null for empty old text', () => { + expect(computeIncrementalSelection('', 'hello')).toBeNull(); + }); + + it('detects single character insertion mid-paragraph', () => { + const result = computeIncrementalSelection( + 'First paragraph.\nSecond pragraph.\nThird paragraph.', + 'First paragraph.\nSecond paragraph.\nThird paragraph.' + ); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(17); + expect(result!.selection.end).toBe(35); + expect(result!.delta).toBe(1); + }); + + it('detects deletion in a paragraph', () => { + const result = computeIncrementalSelection('Hello world.\nBye world.', 'Hello.\nBye world.'); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(0); + expect(result!.selection.end).toBe(7); + expect(result!.delta).toBe(-6); + }); + + it('expands to paragraph boundaries', () => { + const result = computeIncrementalSelection('AAA\nBBB\nCCC', 'AAA\nBXB\nCCC'); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(4); + expect(result!.selection.end).toBe(8); + }); + + it('returns null when change covers most of the text', () => { + const result = computeIncrementalSelection('ABCDE', 'XYZWV'); + expect(result).toBeNull(); + }); + + it('returns null when single paragraph text changes', () => { + const result = computeIncrementalSelection( + 'Hello world, this is a test', + 'Hello world, this is a tset' + ); + expect(result).toBeNull(); + }); + + it('handles appending text at the end of a paragraph', () => { + const result = computeIncrementalSelection( + 'First line.\nSecond line.', + 'First line.\nSecond line. More text.' + ); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(12); + expect(result!.delta).toBe(11); + }); + + it('handles insertion of a new paragraph', () => { + const result = computeIncrementalSelection('AAA\nCCC', 'AAA\nBBB\nCCC'); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(4); + expect(result!.selection.end).toBe(8); + expect(result!.delta).toBe(4); + }); + + it('handles change at the very start', () => { + const result = computeIncrementalSelection('Hello.\nWorld.', 'Hi.\nWorld.'); + expect(result).not.toBeNull(); + expect(result!.selection.start).toBe(0); + expect(result!.selection.end).toBe(4); + }); +}); + +describe('shiftCorrections', () => { + const makeCorrection = (start: number, end: number): ProofreadCorrection => ({ + startIndex: start, + endIndex: end, + correction: 'fix', + }); + + it('returns corrections unchanged when delta is 0', () => { + const corrections = [makeCorrection(0, 5), makeCorrection(10, 15)]; + expect(shiftCorrections(corrections, 8, 0)).toEqual(corrections); + }); + + it('shifts corrections after the edit point by positive delta', () => { + const corrections = [makeCorrection(0, 3), makeCorrection(10, 15)]; + const shifted = shiftCorrections(corrections, 5, 2); + expect(shifted[0]).toEqual(makeCorrection(0, 3)); + expect(shifted[1]).toEqual(makeCorrection(12, 17)); + }); + + it('shifts corrections after the edit point by negative delta', () => { + const corrections = [makeCorrection(0, 3), makeCorrection(10, 15)]; + const shifted = shiftCorrections(corrections, 5, -2); + expect(shifted[0]).toEqual(makeCorrection(0, 3)); + expect(shifted[1]).toEqual(makeCorrection(8, 13)); + }); + + it('does not shift corrections before the edit point', () => { + const corrections = [makeCorrection(0, 3), makeCorrection(10, 15)]; + const shifted = shiftCorrections(corrections, 20, 5); + expect(shifted).toEqual(corrections); + }); + + it('shifts corrections exactly at the edit point', () => { + const corrections = [makeCorrection(5, 10)]; + const shifted = shiftCorrections(corrections, 5, 3); + expect(shifted[0]).toEqual(makeCorrection(8, 13)); + }); +}); diff --git a/src/shared/utils/text-diff.ts b/src/shared/utils/text-diff.ts new file mode 100644 index 0000000..610640c --- /dev/null +++ b/src/shared/utils/text-diff.ts @@ -0,0 +1,87 @@ +import type { ProofreadCorrection } from '../types.ts'; +import type { ProofreadSelectionRange } from '../proofreading/controller.ts'; + +export interface TextDiffResult { + selection: ProofreadSelectionRange; + delta: number; + oldDiffEnd: number; +} + +const INCREMENTAL_THRESHOLD = 0.7; + +export function computeIncrementalSelection( + oldText: string, + newText: string +): TextDiffResult | null { + if (oldText === newText || oldText.length === 0) { + return null; + } + + let prefixLen = 0; + const minLen = Math.min(oldText.length, newText.length); + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + let oldSuffix = oldText.length; + let newSuffix = newText.length; + while ( + oldSuffix > prefixLen && + newSuffix > prefixLen && + oldText[oldSuffix - 1] === newText[newSuffix - 1] + ) { + oldSuffix--; + newSuffix--; + } + + if (prefixLen === oldSuffix && prefixLen === newSuffix) { + return null; + } + + let paraStart = prefixLen; + while (paraStart > 0 && newText[paraStart - 1] !== '\n') { + paraStart--; + } + + let paraEnd = newSuffix; + if (paraEnd <= 0 || newText[paraEnd - 1] !== '\n') { + while (paraEnd < newText.length && newText[paraEnd] !== '\n') { + paraEnd++; + } + if (paraEnd < newText.length && newText[paraEnd] === '\n') { + paraEnd++; + } + } + + const selectionSize = paraEnd - paraStart; + if (selectionSize >= newText.length * INCREMENTAL_THRESHOLD) { + return null; + } + + return { + selection: { start: paraStart, end: paraEnd }, + delta: newText.length - oldText.length, + oldDiffEnd: oldSuffix, + }; +} + +export function shiftCorrections( + corrections: ProofreadCorrection[], + oldDiffEnd: number, + delta: number +): ProofreadCorrection[] { + if (delta === 0) { + return corrections; + } + + return corrections.map((c) => { + if (c.startIndex >= oldDiffEnd) { + return { + ...c, + startIndex: c.startIndex + delta, + endIndex: c.endIndex + delta, + }; + } + return c; + }); +}