InfoLens / client /src /ts /controllers /textInputController.ts
dqy08's picture
initial beta release
494c9e4
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,
updateApiUsageDisplay,
validateMetricsElements,
type ApiTokenUsage
} from '../utils/textMetricsUpdater';
import { tr } from '../lang/i18n-lite';
/**
* 扩展的 Input 事件接口
* 用于在 input 事件中传递额外的标志信息
*/
export interface ExtendedInputEvent extends Event {
isMatchingAnalysis?: boolean;
}
export type TextInputControllerOptions = {
textField: d3.Selection<any, unknown, any, any>;
textCountValue: d3.Selection<any, unknown, any, any>;
/** 首页等由 AppStateManager 控制显隐;未传则仅不持有引用 */
textMetrics?: d3.Selection<any, unknown, any, any>;
/** 首页等:bytes / chars / tokens / surprisal;Chat 页省略,改用 metricUsage */
metricBytes?: d3.Selection<any, unknown, any, any>;
metricChars?: d3.Selection<any, unknown, any, any>;
metricTokens?: d3.Selection<any, unknown, any, any>;
metricTotalSurprisal?: d3.Selection<any, unknown, any, any>;
/** Chat:仅展示 API 返回的 usage(由 chat 页集中调用 updateChatCompletionMetrics 时可不传) */
metricUsage?: d3.Selection<any, unknown, any, any>;
metricModel?: d3.Selection<any, unknown, any, any>;
clearBtn: d3.Selection<any, unknown, any, any>;
submitBtn: d3.Selection<any, unknown, any, any>;
saveBtn: d3.Selection<any, unknown, any, any>;
pasteBtn: d3.Selection<any, unknown, any, any>;
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.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 模型名称,始终显示以反映原始情况
* @param apiUsage 可选:后端 tokenizer 计数(如 completions 的 usage)
*/
public updateTextMetrics(
stats: TextStats | null,
modelName?: string | null | undefined,
apiUsage?: ApiTokenUsage | null
): void {
const {
metricBytes,
metricChars,
metricTokens,
metricTotalSurprisal,
metricUsage,
metricModel,
totalSurprisalFormat
} = this.options;
// Chat:仅 model + API usage
if (metricUsage && !metricUsage.empty()) {
if (
!metricModel ||
metricModel.empty() ||
!validateMetricsElements(metricUsage, metricModel)
) {
return;
}
updateApiUsageDisplay(metricUsage, apiUsage ?? null);
updateModel(metricModel, modelName);
return;
}
if (
!metricBytes ||
!metricChars ||
!metricTokens ||
!metricTotalSurprisal ||
!metricModel ||
metricModel.empty() ||
!validateMetricsElements(
metricBytes,
metricChars,
metricTokens,
metricTotalSurprisal,
metricModel
)
) {
return;
}
if (stats) {
updateBasicMetrics(metricBytes, metricChars, metricTokens, stats, apiUsage);
updateTotalSurprisal(metricTotalSurprisal, stats, totalSurprisalFormat);
}
updateModel(metricModel, modelName);
}
/**
* 处理清空文本
*/
private handleClear(): void {
const textValue = this.options.textField.property('value') || '';
if (textValue.length === 0) {
return;
}
this.options.textField.property('value', '');
// 触发 input 事件,让外部统一处理状态更新
this.options.textField.node()?.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* 处理粘贴
*/
private async handlePaste(): Promise<void> {
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);
};