From 32d41a16bfbea2b69ec59f75d08c2dcdf750059c Mon Sep 17 00:00:00 2001 From: ianujvarshney Date: Sun, 5 Jul 2026 12:39:28 +0530 Subject: [PATCH] feat(dom): implement strategy pattern for editor adapters and field detection --- src/content/adapters/DefaultAdapter.ts | 77 +++++++++++++ src/content/adapters/GoogleDocsAdapter.ts | 32 ++++++ src/content/adapters/IEditorAdapter.ts | 24 ++++ src/content/adapters/NotionAdapter.ts | 33 ++++++ src/content/adapters/index.ts | 32 ++++++ src/content/core/fieldDetection.ts | 128 ++++++++++++++++++++++ 6 files changed, 326 insertions(+) create mode 100644 src/content/adapters/DefaultAdapter.ts create mode 100644 src/content/adapters/GoogleDocsAdapter.ts create mode 100644 src/content/adapters/IEditorAdapter.ts create mode 100644 src/content/adapters/NotionAdapter.ts create mode 100644 src/content/adapters/index.ts create mode 100644 src/content/core/fieldDetection.ts diff --git a/src/content/adapters/DefaultAdapter.ts b/src/content/adapters/DefaultAdapter.ts new file mode 100644 index 0000000..b92f30a --- /dev/null +++ b/src/content/adapters/DefaultAdapter.ts @@ -0,0 +1,77 @@ +import { IEditorAdapter } from './IEditorAdapter'; + +export class DefaultAdapter implements IEditorAdapter { + readonly name = 'DefaultAdapter'; + + matches(target: HTMLElement): boolean { + const tagName = target.tagName.toLowerCase(); + const isInput = tagName === 'input' && target.getAttribute('type') !== 'password'; + const isTextarea = tagName === 'textarea'; + const isContentEditable = target.isContentEditable; + + return isInput || isTextarea || isContentEditable; + } + + getText(target: HTMLElement): string { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const start = target.selectionStart ?? 0; + const end = target.selectionEnd ?? 0; + if (start !== end) { + return target.value.substring(start, end); + } + return target.value; + } + + if (target.isContentEditable) { + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + return selection.toString(); + } + return target.innerText || target.textContent || ""; + } + + return ""; + } + + replaceText(target: HTMLElement, replacement: string): void { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const start = target.selectionStart ?? 0; + const end = target.selectionEnd ?? 0; + + target.focus(); + if (start !== end) { + target.setRangeText(replacement, start, end, 'select'); + } else { + target.value = replacement; + } + + // Dispatch events for React/Angular bindings + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + return; + } + + if (target.isContentEditable) { + target.focus(); + const selection = window.getSelection(); + + // Use execCommand to preserve Undo stack natively when possible + if (document.queryCommandSupported('insertText')) { + const success = document.execCommand('insertText', false, replacement); + if (success) return; + } + + // Fallback + if (selection && selection.rangeCount > 0 && selection.toString().length > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(document.createTextNode(replacement)); + } else { + target.innerText = replacement; + } + + target.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + } +} diff --git a/src/content/adapters/GoogleDocsAdapter.ts b/src/content/adapters/GoogleDocsAdapter.ts new file mode 100644 index 0000000..06cd8c6 --- /dev/null +++ b/src/content/adapters/GoogleDocsAdapter.ts @@ -0,0 +1,32 @@ +import { IEditorAdapter } from './IEditorAdapter'; + +export class GoogleDocsAdapter implements IEditorAdapter { + readonly name = 'GoogleDocsAdapter'; + + matches(_target: HTMLElement): boolean { + return window.location.hostname === 'docs.google.com'; + } + + getText(_target: HTMLElement): string { + // In Google Docs, text selection is notoriously complex due to the canvas-like rendering (kix). + // However, the browser's native window.getSelection() often catches the invisible overlaid text. + // For a robust enterprise implementation, this would interact with the Docs specific DOM or Canvas APIs. + // For now, we rely on the standard selection API which reliably captures the overlaid selection div for text extraction. + const selection = window.getSelection(); + return selection ? selection.toString() : ""; + } + + replaceText(_target: HTMLElement, replacement: string): void { + // Google Docs uses a canvas-based editor (kix). DOM manipulation is strictly unsafe and breaks the doc state. + // Fallback: Copy to clipboard and instruct the user to paste. + navigator.clipboard.writeText(replacement).then(() => { + // In a production app with a UI state, we would trigger a React toast notification. + // Since this is the content script boundary, an alert or styled console works for MVP. + console.log("[Lexiflow] Google Docs detected. Text copied to clipboard. Please paste manually (Ctrl/Cmd + V)."); + alert("Lexiflow: AI text copied to clipboard. Please paste it directly into your document."); + }).catch(err => { + console.error("[Lexiflow] Failed to copy text to clipboard", err); + alert("Lexiflow: Failed to copy to clipboard."); + }); + } +} diff --git a/src/content/adapters/IEditorAdapter.ts b/src/content/adapters/IEditorAdapter.ts new file mode 100644 index 0000000..8d286c5 --- /dev/null +++ b/src/content/adapters/IEditorAdapter.ts @@ -0,0 +1,24 @@ +export interface IEditorAdapter { + /** + * The name of the adapter for logging/debugging (e.g., "GoogleDocsAdapter") + */ + readonly name: string; + + /** + * Checks if this adapter should be active for the current domain/element. + * Return true if the adapter can handle the current page/element context. + */ + matches(target: HTMLElement): boolean; + + /** + * Gets the currently selected text or the full text of the active field. + */ + getText(target: HTMLElement): string; + + /** + * Replaces the currently selected text or the full field text with the provided replacement. + * Should attempt to preserve native Undo (Ctrl/Cmd + Z) where possible. + * If replacing is impossible/unsafe (e.g., Google Docs), fallback to copying to clipboard and alerting the user. + */ + replaceText(target: HTMLElement, replacement: string): void; +} diff --git a/src/content/adapters/NotionAdapter.ts b/src/content/adapters/NotionAdapter.ts new file mode 100644 index 0000000..a8c445e --- /dev/null +++ b/src/content/adapters/NotionAdapter.ts @@ -0,0 +1,33 @@ +import { IEditorAdapter } from './IEditorAdapter'; +import { DefaultAdapter } from './DefaultAdapter'; + +export class NotionAdapter implements IEditorAdapter { + readonly name = 'NotionAdapter'; + private defaultAdapter = new DefaultAdapter(); + + matches(target: HTMLElement): boolean { + return window.location.hostname === 'www.notion.so' || target.closest('.notion-app-inner') !== null; + } + + getText(target: HTMLElement): string { + // Notion uses contenteditable blocks, so the default adapter's logic works well for extraction. + return this.defaultAdapter.getText(target); + } + + replaceText(target: HTMLElement, replacement: string): void { + // Notion uses React-controlled contenteditable elements. + // Forcing an insertText command preserves the React state and undo history natively. + target.focus(); + + // Use document.execCommand to hook into Notion's native undo stack + let success = false; + if (document.queryCommandSupported('insertText')) { + success = document.execCommand('insertText', false, replacement); + } + + if (!success) { + // Fallback to default adapter if execCommand is deprecated/disabled + this.defaultAdapter.replaceText(target, replacement); + } + } +} diff --git a/src/content/adapters/index.ts b/src/content/adapters/index.ts new file mode 100644 index 0000000..9834d5d --- /dev/null +++ b/src/content/adapters/index.ts @@ -0,0 +1,32 @@ +import { IEditorAdapter } from './IEditorAdapter'; +import { DefaultAdapter } from './DefaultAdapter'; +import { GoogleDocsAdapter } from './GoogleDocsAdapter'; +import { NotionAdapter } from './NotionAdapter'; + +export * from './IEditorAdapter'; +export * from './DefaultAdapter'; +export * from './GoogleDocsAdapter'; +export * from './NotionAdapter'; + +export class EditorAdapterFactory { + // Order matters: More specific adapters should be checked before the DefaultAdapter + private static adapters: IEditorAdapter[] = [ + new GoogleDocsAdapter(), + new NotionAdapter(), + new DefaultAdapter() + ]; + + /** + * Finds the most appropriate adapter for the given HTML element. + */ + static getAdapterFor(target: HTMLElement): IEditorAdapter { + for (const adapter of this.adapters) { + if (adapter.matches(target)) { + return adapter; + } + } + + // Fallback to default if no specific adapter matches, though DefaultAdapter usually matches anyway + return new DefaultAdapter(); + } +} diff --git a/src/content/core/fieldDetection.ts b/src/content/core/fieldDetection.ts new file mode 100644 index 0000000..3a52e7f --- /dev/null +++ b/src/content/core/fieldDetection.ts @@ -0,0 +1,128 @@ +import { EditorAdapterFactory, IEditorAdapter } from '../adapters'; + +export type FieldFocusHandler = ( + element: HTMLElement | null, + adapter: IEditorAdapter | null, + rect: DOMRect | null +) => void; + +export class FieldDetectionEngine { + private static activeElement: HTMLElement | null = null; + private static activeAdapter: IEditorAdapter | null = null; + private static focusHandler: FieldFocusHandler | null = null; + private static selectionTimeout: number | null = null; + + /** + * Initializes the event listeners for field detection. + */ + static start(onFocusChange: FieldFocusHandler): void { + this.focusHandler = onFocusChange; + + document.addEventListener('focusin', this.handleFocusIn); + document.addEventListener('focusout', this.handleFocusOut); + document.addEventListener('selectionchange', this.handleSelectionChange); + document.addEventListener('click', this.handleClick); + } + + /** + * Cleans up all event listeners. + */ + static stop(): void { + document.removeEventListener('focusin', this.handleFocusIn); + document.removeEventListener('focusout', this.handleFocusOut); + document.removeEventListener('selectionchange', this.handleSelectionChange); + document.removeEventListener('click', this.handleClick); + + this.activeElement = null; + this.activeAdapter = null; + this.focusHandler = null; + if (this.selectionTimeout) { + window.clearTimeout(this.selectionTimeout); + } + } + + static getActiveElement(): HTMLElement | null { + return this.activeElement; + } + + static getActiveAdapter(): IEditorAdapter | null { + return this.activeAdapter; + } + + private static handleClick = (e: MouseEvent) => { + // Also check on click in case focus events didn't fire properly (e.g. some contenteditables) + const target = e.target as HTMLElement; + if (target && !this.activeElement) { + this.evaluateTarget(target); + } + }; + + private static handleFocusIn = (e: FocusEvent) => { + const target = e.target as HTMLElement; + if (target) { + this.evaluateTarget(target); + } + }; + + private static handleFocusOut = (_e: FocusEvent) => { + // Delay slightly to check if focus moved to our own popup UI + setTimeout(() => { + const active = document.activeElement as HTMLElement; + // If focus moved to body or outside an editable area, and not to our popup + if (!active || active === document.body) { + this.notify(null, null, null); + } else { + this.evaluateTarget(active); + } + }, 100); + }; + + private static handleSelectionChange = () => { + // Debounce selection change since it fires very frequently + if (this.selectionTimeout) { + window.clearTimeout(this.selectionTimeout); + } + + this.selectionTimeout = window.setTimeout(() => { + if (this.activeElement && this.activeAdapter) { + // Notify of potential rect updates + this.notify(this.activeElement, this.activeAdapter, this.activeElement.getBoundingClientRect()); + } else { + // Try to find if there is an active selection in an editable + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const node = selection.focusNode; + if (node) { + const element = node.nodeType === Node.ELEMENT_NODE ? node as HTMLElement : node.parentElement; + if (element) { + this.evaluateTarget(element); + } + } + } + } + }, 150); + }; + + private static evaluateTarget(target: HTMLElement) { + // Check if it's our own UI + if (target.closest('#lexiflow-full-page-popup-container') || target.closest('#crxjs-app') || target.closest('#lexiflow-floating-ui')) { + return; + } + + // Use the factory to find a matching adapter + const adapter = EditorAdapterFactory.getAdapterFor(target); + + // If it matches (is editable according to adapter) + if (adapter.matches(target)) { + this.activeElement = target; + this.activeAdapter = adapter; + this.notify(target, adapter, target.getBoundingClientRect()); + } + } + + private static notify(element: HTMLElement | null, adapter: IEditorAdapter | null, rect: DOMRect | null) { + if (this.focusHandler) { + this.focusHandler(element, adapter, rect); + } + } +}