import * as d3 from 'd3'; import type { TextStats } from '../utils/textStatistics'; import { calculateTextStats } from '../utils/textStatistics'; import { countTokenCharacters } from '../utils/Util'; import type { FrontendAnalyzeResult } from '../api/GLTR_API'; import { updateBasicMetrics, updateTotalSurprisal, updateModel, validateMetricsElements } from '../utils/textMetricsUpdater'; import { tr } from '../lang/i18n-lite'; /** * 扩展的 Input 事件接口 * 用于在 input 事件中传递额外的标志信息 */ export interface ExtendedInputEvent extends Event { isMatchingAnalysis?: boolean; } export type TextInputControllerOptions = { textField: d3.Selection; textCountValue: d3.Selection; textMetrics: d3.Selection; metricBytes: d3.Selection; metricChars: d3.Selection; metricTokens: d3.Selection; metricTotalSurprisal: d3.Selection; metricModel: d3.Selection; clearBtn: d3.Selection; submitBtn: d3.Selection; saveBtn: d3.Selection; pasteBtn: d3.Selection; totalSurprisalFormat: (value: number | null) => string; showAlertDialog: (title: string, message: string) => void; }; export class TextInputController { private options: TextInputControllerOptions; constructor(options: TextInputControllerOptions) { this.options = options; this.initialize(); } private initialize(): void { // 初始化时检查一次按钮状态 this.updateButtonStates(); // Clear 按钮状态完全由 TextInputController 内部管理 // 使用原生 addEventListener 监听 input 事件,避免被 D3 的 .on() 覆盖 // 这样可以允许多个监听器共存 const textFieldNode = this.options.textField.node() as HTMLTextAreaElement | null; if (textFieldNode) { textFieldNode.addEventListener('input', () => { this.updateButtonStates(); }); } // Clear 按钮点击事件 this.options.clearBtn.on('click', () => { this.handleClear(); }); // Paste 按钮点击事件 this.options.pasteBtn.on('click', async () => { await this.handlePaste(); }); } /** * 更新按钮有效性和字符计数(私有方法,仅内部使用) * 只负责更新 Clear 按钮状态和字符计数 * 注意:submitBtn 和 saveBtn 的状态由外部状态系统统一管理 */ private updateButtonStates(): void { const textValue = this.options.textField.property('value') || ''; const hasText = textValue.trim().length > 0; // Clear按钮:只在文本框有内容时有效 this.options.clearBtn.classed('inactive', !hasText); // 注意:submitBtn 的状态现在由外部状态系统统一管理,不再在这里设置 if (!this.options.textCountValue.empty()) { const charCount = countTokenCharacters(textValue); this.options.textCountValue.text(charCount.toString()); } } /** * 更新文本指标内容(包括模型显示,不控制显示/隐藏,显示/隐藏由 AppStateManager 统一管理) * @param stats 统计数据,为 null 时不更新统计内容 * @param modelName 模型名称,始终显示以反映原始情况 */ public updateTextMetrics(stats: TextStats | null, modelName?: string | null | undefined): void { const { metricBytes, metricChars, metricTokens, metricTotalSurprisal, metricModel, totalSurprisalFormat } = this.options; // 检查必要的元素是否存在 if (!validateMetricsElements(metricBytes, metricChars, metricTokens, metricTotalSurprisal, metricModel)) { return; } // 更新统计指标内容(如果有统计数据) if (stats) { updateBasicMetrics(metricBytes, metricChars, metricTokens, stats); updateTotalSurprisal(metricTotalSurprisal, stats, totalSurprisalFormat); } // 更新模型显示(始终显示以反映原始情况) // 显示/隐藏由 AppStateManager 通过 textMetrics 容器统一管理 updateModel(metricModel, modelName); } /** * 处理清空文本 */ private handleClear(): void { const textValue = this.options.textField.property('value') || ''; if (!textValue.trim()) { return; // 如果没有文本,直接返回 } this.options.textField.property('value', ''); // 触发 input 事件,让外部统一处理状态更新 this.options.textField.node()?.dispatchEvent(new Event('input', { bubbles: true })); } /** * 处理粘贴 */ private async handlePaste(): Promise { try { const text = await navigator.clipboard.readText(); if (text) { const currentValue = this.options.textField.property('value') || ''; // 在光标位置插入,如果没有光标或光标在末尾,则追加 const textarea = this.options.textField.node() as HTMLTextAreaElement; if (textarea) { const start = textarea.selectionStart || currentValue.length; const end = textarea.selectionEnd || currentValue.length; const newValue = currentValue.substring(0, start) + text + currentValue.substring(end); this.options.textField.property('value', newValue); // 设置光标位置到粘贴内容的末尾 textarea.setSelectionRange(start + text.length, start + text.length); } else { this.options.textField.property('value', currentValue + text); } // 触发 input 事件,让外部统一处理状态更新 this.options.textField.node()?.dispatchEvent(new Event('input', { bubbles: true })); } } catch (error) { console.error('粘贴失败:', error); // 如果clipboard API不可用,提示用户手动粘贴 this.options.showAlertDialog(tr('Info'), tr('Failed to read clipboard, please paste manually')); } } /** * 获取当前文本框的值 */ public getTextValue(): string { return this.options.textField.property('value') || ''; } /** * 设置文本框的值 * @param value 要设置的文本值 * @param isMatchingAnalysis 如果为true,表示这是匹配分析结果的文本填入(如加载demo),不会清除hasValidData * 如果为false或未提供,表示这是单方面的文本修改(如用户输入、预填充),会清除hasValidData */ public setTextValue(value: string, isMatchingAnalysis: boolean = false): void { this.options.textField.property('value', value); // 触发 input 事件,添加标志以区分两种场景 const event = new Event('input', { bubbles: true }) as ExtendedInputEvent; event.isMatchingAnalysis = isMatchingAnalysis; this.options.textField.node()?.dispatchEvent(event); } } /** * 计算文本统计信息(便捷函数) */ export const calculateTextStatsForController = ( result: FrontendAnalyzeResult, originalText: string ): TextStats => { return calculateTextStats(result, originalText); };