Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/content/adapters/DefaultAdapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
32 changes: 32 additions & 0 deletions src/content/adapters/GoogleDocsAdapter.ts
Original file line number Diff line number Diff line change
@@ -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.");
});
}
}
24 changes: 24 additions & 0 deletions src/content/adapters/IEditorAdapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
33 changes: 33 additions & 0 deletions src/content/adapters/NotionAdapter.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
32 changes: 32 additions & 0 deletions src/content/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
128 changes: 128 additions & 0 deletions src/content/core/fieldDetection.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}