File size: 7,850 Bytes
e5d8d3a
 
e9c9e16
 
e5d8d3a
419dfa0
bb35e88
e5d8d3a
fc062b2
 
 
 
 
 
 
 
e5d8d3a
 
 
 
 
 
 
 
 
 
 
 
 
6284eeb
e5d8d3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605cef2
 
 
 
 
 
 
 
 
e5d8d3a
 
 
 
 
 
 
 
 
 
 
 
 
605cef2
 
 
e5d8d3a
605cef2
e5d8d3a
 
 
 
 
 
605cef2
e5d8d3a
 
e9c9e16
e5d8d3a
 
 
 
 
6284eeb
 
 
e5d8d3a
6284eeb
e5d8d3a
 
 
 
 
6284eeb
e5d8d3a
 
 
 
419dfa0
e5d8d3a
 
 
6284eeb
 
419dfa0
 
e5d8d3a
 
6284eeb
 
419dfa0
e5d8d3a
 
 
 
 
 
 
 
 
 
 
605cef2
 
e5d8d3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605cef2
 
e5d8d3a
 
 
 
bb35e88
e5d8d3a
 
 
 
 
 
 
 
 
 
 
 
fc062b2
 
 
e5d8d3a
fc062b2
e5d8d3a
fc062b2
 
 
 
e5d8d3a
 
 
 
 
 
 
 
e9c9e16
e5d8d3a
e9c9e16
e5d8d3a
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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<any, unknown, any, any>;
    textCountValue: d3.Selection<any, unknown, any, any>;
    textMetrics: d3.Selection<any, unknown, any, any>;
    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>;
    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.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<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);
};