@sendevia/pi-ask-author 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -114
- package/dist/component.d.ts +322 -0
- package/{src/format.ts → dist/format.d.ts} +12 -118
- package/dist/index.d.ts +51 -0
- package/dist/index.js +38 -0
- package/dist/model.d.ts +260 -0
- package/dist/novel-markdown.d.ts +28 -0
- package/dist/sanitize.d.ts +18 -0
- package/dist/schema.d.ts +129 -0
- package/dist/texts.d.ts +494 -0
- package/{src/theme.ts → dist/theme.d.ts} +0 -2
- package/dist/view-rows.d.ts +69 -0
- package/dist/view-summary.d.ts +27 -0
- package/package.json +9 -10
- package/src/component.ts +0 -1219
- package/src/index.ts +0 -419
- package/src/model.ts +0 -748
- package/src/novel-markdown.ts +0 -673
- package/src/sanitize.ts +0 -279
- package/src/schema.ts +0 -108
- package/src/texts.ts +0 -636
- package/src/view-rows.ts +0 -217
- package/src/view-summary.ts +0 -110
- package/tsconfig.json +0 -16
package/src/component.ts
DELETED
|
@@ -1,1219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file component.ts
|
|
3
|
-
* @description `ask_author` 交互式 TUI 主组件(双栏问卷视图与答卷总览)
|
|
4
|
-
*
|
|
5
|
-
* 核心架构特性:
|
|
6
|
-
* - 宽屏双栏 / 窄屏单栏自适应:`renderWidth >= LAYOUT_CONFIG.splitViewMinWidth` 且题目含富内容时走双栏,否则降级单栏紧凑列表;预览与总览视口行数随终端高度收缩
|
|
7
|
-
* - 多级缓存失效:整屏(尺寸键 + 状态突变清空)、总览 / Tab 栏 / 左栏 / 菜单项(`revision` 键)、预览池(内容键);视口切片 O(1);`render()` 无 I/O,Markdown 渲染仅发生在派生缓存未命中时
|
|
8
|
-
* - 逐题独立光标记忆与 `1~9` 跳转题目;内置编辑器透传 Focusable 以定位 IME 候选框
|
|
9
|
-
* - Esc / Ctrl+C 双通道取消(输入模式二次确认);定时器、Abort 监听与缓存池全量回收
|
|
10
|
-
*
|
|
11
|
-
* 渲染纯度不变量:`render()` 及其可达被调函数禁止写入语义状态(`currentTab`、光标与各 `*ScrollOffset`、`inputMode`、`editTarget`、`editorEscArmed`、`statusMessage`、`isFinished`);滚动位移归一化一律归属输入路径
|
|
12
|
-
* 可写入透明备忘:`cached*`、`previewCache`、`tabCache`、`leftColCache`、`summaryCachedLines`、`menuCache`、`previewViewportObservation` 均为「渲染输入的纯函数」缓存
|
|
13
|
-
*
|
|
14
|
-
* 依赖方向:component.ts → theme / texts / format / model / novel-markdown / view-rows / view-summary;本组件只做按键路由、视口切片与整屏缓存编排
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
Editor,
|
|
19
|
-
Key,
|
|
20
|
-
matchesKey,
|
|
21
|
-
visibleWidth,
|
|
22
|
-
type Component,
|
|
23
|
-
type EditorTheme,
|
|
24
|
-
type Focusable,
|
|
25
|
-
type TUI,
|
|
26
|
-
} from "@earendil-works/pi-tui";
|
|
27
|
-
import { clamp, clampWindowOffset, padToVisibleWidth, pushWrapped, safeLine, windowSlice } from "./format.js";
|
|
28
|
-
import {
|
|
29
|
-
AskAuthorFormModel,
|
|
30
|
-
hasQuestionNote,
|
|
31
|
-
type AskAuthorResult,
|
|
32
|
-
type EditTarget,
|
|
33
|
-
type MenuItem,
|
|
34
|
-
type NormalizedQuestion,
|
|
35
|
-
type QuestionAnswerState,
|
|
36
|
-
} from "./model.js";
|
|
37
|
-
import { buildFullPreviewContentLines } from "./novel-markdown.js";
|
|
38
|
-
import { LAYOUT_CONFIG, TEXTS } from "./texts.js";
|
|
39
|
-
import type { Theme } from "./theme.js";
|
|
40
|
-
import { buildTabLines, formatMenuItemRow, optionLabelOf, pushFrameRule, pushHelpFooter } from "./view-rows.js";
|
|
41
|
-
import { buildSubmitSummaryContentLines, buildSummaryHelpParts } from "./view-summary.js";
|
|
42
|
-
|
|
43
|
-
/** 预览缓存池容量上限(条目数),防止长会话下缓存无界增长 */
|
|
44
|
-
const PREVIEW_CACHE_CAPACITY = 32;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* `ask_author` 交互式 TUI 组件(问卷作答与答卷总览)
|
|
48
|
-
* 状态机:作答突变经 `AskAuthorFormModel`,`revision` 驱动两级缓存失效;`render()` 只写入透明备忘 / 观察缓存,绝不写回滚动位移等语义状态;`dispose()` 幂等且只设置 `disposed`,`finish()` 以 `isFinished` 保证 `done` 至多投递一次
|
|
49
|
-
*/
|
|
50
|
-
export class AskAuthorComponent implements Component, Focusable {
|
|
51
|
-
/** Focusable 接口要求:输入模式焦点指示 */
|
|
52
|
-
private _focused = false;
|
|
53
|
-
/** 当前焦点状态(仅 `true` 时透传内置 Editor 以定位 IME 候选框) */
|
|
54
|
-
get focused(): boolean {
|
|
55
|
-
return this._focused;
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* 设置焦点状态并级联透传至内置 Editor(同值为无操作)
|
|
59
|
-
* @param value - 目标焦点状态;同时失效整屏 / Tab 栏 / 左栏缓存
|
|
60
|
-
*/
|
|
61
|
-
set focused(value: boolean) {
|
|
62
|
-
if (this._focused === value) return;
|
|
63
|
-
this._focused = value;
|
|
64
|
-
this.editor.focused = this.inputMode && value;
|
|
65
|
-
// Editor 的 IME 光标标记仅经由 renderInputMode 进入整屏缓存;两栏一并失效以防御后续布局把标记带入其他行
|
|
66
|
-
this.touch();
|
|
67
|
-
this.tabCache = undefined;
|
|
68
|
-
this.leftColCache = undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** TUI 上下文 */
|
|
72
|
-
private readonly tui: TUI;
|
|
73
|
-
/** 主题 */
|
|
74
|
-
private readonly theme: Theme;
|
|
75
|
-
/** 问卷模型(作答状态唯一权威,mutation 自动推进 revision) */
|
|
76
|
-
private readonly model: AskAuthorFormModel;
|
|
77
|
-
/** 结果达成回调 */
|
|
78
|
-
private readonly done: (result: AskAuthorResult) => void;
|
|
79
|
-
|
|
80
|
-
/** 当前 Tab 索引(0-based;questionCount 为总览确认页) */
|
|
81
|
-
private currentTab = 0;
|
|
82
|
-
/** 每道题独立的光标索引记忆表 */
|
|
83
|
-
private readonly cursorByTab = new Map<number, number>();
|
|
84
|
-
/** 当前题目菜单内选中的条目索引 */
|
|
85
|
-
private currentOptionIndex = 0;
|
|
86
|
-
/** 左侧菜单列表滚动位移(行) */
|
|
87
|
-
private listScrollOffset = 0;
|
|
88
|
-
/** 详情预览区滚动位移(行) */
|
|
89
|
-
private previewScrollOffset = 0;
|
|
90
|
-
/** 答卷总览页滚动位移(行) */
|
|
91
|
-
private summaryScrollOffset = 0;
|
|
92
|
-
|
|
93
|
-
/** 是否处于补充说明文本输入模式 */
|
|
94
|
-
private inputMode = false;
|
|
95
|
-
/** Esc 丢弃确认武装标记:编辑器非空时首次 Esc 仅武装,再次 Esc 才丢弃内容 */
|
|
96
|
-
private editorEscArmed = false;
|
|
97
|
-
/** 底部临时状态通知文案 */
|
|
98
|
-
private statusMessage: string | null = null;
|
|
99
|
-
/** 状态通知定时器 */
|
|
100
|
-
private statusTimer: ReturnType<typeof setTimeout> | null = null;
|
|
101
|
-
/** 是否已终结(终结后忽略一切输入,且 `done` 不再投递) */
|
|
102
|
-
private isFinished = false;
|
|
103
|
-
/** 是否已卸载回收(幂等标记;卸载后忽略迟到输入且拒绝再绑定 Abort 监听;`done` 投递不受本标记阻断,见 {@link dispose}) */
|
|
104
|
-
private disposed = false;
|
|
105
|
-
|
|
106
|
-
/** AbortSignal 监听绑定对象 */
|
|
107
|
-
private abortBinding?: { signal: AbortSignal; onAbort: () => void };
|
|
108
|
-
|
|
109
|
-
/** 整屏缓存键:调用方真实列宽(输出截断依据) */
|
|
110
|
-
private cachedWidth?: number;
|
|
111
|
-
/** 整屏缓存键:布局列宽 `max(20, width)`(折行依据,20 列为窄终端宽度下限) */
|
|
112
|
-
private cachedLayoutWidth?: number;
|
|
113
|
-
/** 整屏缓存键:终端行数(视口行数随之自适应) */
|
|
114
|
-
private cachedHeight?: number;
|
|
115
|
-
/** 整屏已格式化终端行缓存 */
|
|
116
|
-
private cachedLines?: string[];
|
|
117
|
-
|
|
118
|
-
/** 预览框完整渲染行缓存池(键:题号+条目身份+列宽+补充说明;超 {@link PREVIEW_CACHE_CAPACITY} 按插入序淘汰) */
|
|
119
|
-
private readonly previewCache = new Map<string, string[]>();
|
|
120
|
-
|
|
121
|
-
/** 总览页未切片内容行缓存(键:列宽 + `revision`) */
|
|
122
|
-
private summaryCachedLines?: { width: number; revision: number; lines: string[] };
|
|
123
|
-
|
|
124
|
-
/** Tab 栏行缓存(键:当前页 + `revision` + 列宽) */
|
|
125
|
-
private tabCache?: { key: string; lines: string[] };
|
|
126
|
-
|
|
127
|
-
/** 左栏菜单行缓存(键:题号 + 光标 + 位移 + `revision` + 左栏宽) */
|
|
128
|
-
private leftColCache?: { key: string; lines: string[] };
|
|
129
|
-
|
|
130
|
-
/** 菜单项派生缓存(键:题号 + `revision`,消除 render/输入路径重复构建) */
|
|
131
|
-
private menuCache?: { tab: number; revision: number; items: MenuItem[] };
|
|
132
|
-
|
|
133
|
-
/** 上一帧预览视口的观察元数据(渲染写入、输入路径读取以归一化位移) */
|
|
134
|
-
private previewViewportObservation?: { total: number; viewHeight: number };
|
|
135
|
-
|
|
136
|
-
/** 当前编辑器针对的目标(题目整体或指定选项) */
|
|
137
|
-
private editTarget?: EditTarget;
|
|
138
|
-
|
|
139
|
-
/** 内置补充说明编辑器实例 */
|
|
140
|
-
private readonly editor: Editor;
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 构造交互式 TUI 主组件
|
|
144
|
-
* @param tui - TUI 上下文
|
|
145
|
-
* @param theme - 主题
|
|
146
|
-
* @param model - 问卷模型
|
|
147
|
-
* @param done - 结果回调
|
|
148
|
-
*/
|
|
149
|
-
constructor(tui: TUI, theme: Theme, model: AskAuthorFormModel, done: (result: AskAuthorResult) => void) {
|
|
150
|
-
this.tui = tui;
|
|
151
|
-
this.theme = theme;
|
|
152
|
-
this.model = model;
|
|
153
|
-
this.done = done;
|
|
154
|
-
|
|
155
|
-
const editorTheme: EditorTheme = {
|
|
156
|
-
borderColor: (s) => this.theme.fg("accent", s),
|
|
157
|
-
selectList: {
|
|
158
|
-
selectedPrefix: (t) => this.theme.fg("accent", t),
|
|
159
|
-
selectedText: (t) => this.theme.fg("accent", t),
|
|
160
|
-
description: (t) => this.theme.fg("muted", t),
|
|
161
|
-
scrollInfo: (t) => this.theme.fg("dim", t),
|
|
162
|
-
noMatch: (t) => this.theme.fg("warning", t),
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
this.editor = new Editor(this.tui, editorTheme);
|
|
167
|
-
this.editor.focused = false;
|
|
168
|
-
// 批注突变委托模型 mutation,并按返回语义提示状态栏
|
|
169
|
-
/**
|
|
170
|
-
* 编辑器提交:写入当前目标(选项专属或题目整体)并按模型返回语义提示状态栏
|
|
171
|
-
* @param text - 编辑器全文(空串即清空批注)
|
|
172
|
-
*/
|
|
173
|
-
this.editor.onSubmit = (text) => {
|
|
174
|
-
const target = this.editTarget;
|
|
175
|
-
if (!target) {
|
|
176
|
-
this.closeEditor();
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (target.type === "option") {
|
|
181
|
-
const q = this.model.getQuestion(this.currentTab);
|
|
182
|
-
const optIdx = target.optionIndex;
|
|
183
|
-
// 输入模式下题目与选项集合不可变更,q 缺失仅可能来自异常状态,此时保持自动标签使提示内容易于阅读
|
|
184
|
-
const optLabel = q ? optionLabelOf(q, optIdx) : TEXTS.fallbacks.autoOptionLabel(optIdx + 1);
|
|
185
|
-
const result = this.model.setOptionNote(this.currentTab, optIdx, text);
|
|
186
|
-
if (result === "cleared") this.setStatus(TEXTS.status.optionNoteCleared(optLabel));
|
|
187
|
-
else if (result === "savedAtLimit" && q) this.setStatus(TEXTS.status.optionNoteSavedAtLimit(q.maxSelect));
|
|
188
|
-
else if (result === "savedUnselected") this.setStatus(TEXTS.status.optionNoteSavedUnselected(optLabel));
|
|
189
|
-
else this.setStatus(TEXTS.status.optionNoteSaved(optLabel));
|
|
190
|
-
} else {
|
|
191
|
-
const result = this.model.setQuestionNote(this.currentTab, text);
|
|
192
|
-
this.setStatus(result === "saved" ? TEXTS.status.questionNoteSaved : TEXTS.status.noteCleared);
|
|
193
|
-
}
|
|
194
|
-
this.touchDerived();
|
|
195
|
-
this.closeEditor();
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* 绑定中止信号(幂等,至多绑定一次)
|
|
201
|
-
* @param signal - 外层 AbortSignal;缺省 / 已绑定 / 已卸载时直接返回
|
|
202
|
-
*/
|
|
203
|
-
bindAbortSignal(signal?: AbortSignal): void {
|
|
204
|
-
// disposed 守卫:卸载后再注册的监听器将无人摘除(dispose 已提前返回)
|
|
205
|
-
if (!signal || this.abortBinding || this.disposed) return;
|
|
206
|
-
if (signal.aborted) {
|
|
207
|
-
// 不能在工厂同步调用路径内触发 done(会打断宿主 resolve),故以微任务推迟;finish() 自带幂等守卫
|
|
208
|
-
queueMicrotask(() => this.finish(true));
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const onAbort = () => this.finish(true);
|
|
212
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
213
|
-
this.abortBinding = { signal, onAbort };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* 清理定时器、Abort 监听与全部缓存池(幂等)
|
|
218
|
-
* 只设置 `disposed`、不设置 `isFinished`(资源回收 ≠ `done` 已投递),否则宿主先卸载而 `done` 未投递时挂起
|
|
219
|
-
*/
|
|
220
|
-
dispose(): void {
|
|
221
|
-
if (this.disposed) return;
|
|
222
|
-
this.disposed = true;
|
|
223
|
-
this.clearStatusTimer();
|
|
224
|
-
if (this.abortBinding) {
|
|
225
|
-
this.abortBinding.signal.removeEventListener("abort", this.abortBinding.onAbort);
|
|
226
|
-
this.abortBinding = undefined;
|
|
227
|
-
}
|
|
228
|
-
this.clearDerivedCaches();
|
|
229
|
-
this.previewViewportObservation = undefined;
|
|
230
|
-
this.clearFrameCaches();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** 清空整屏帧缓存(列宽 / 行数键;不触发重绘) */
|
|
234
|
-
private clearFrameCaches(): void {
|
|
235
|
-
this.cachedWidth = undefined;
|
|
236
|
-
this.cachedLayoutWidth = undefined;
|
|
237
|
-
this.cachedHeight = undefined;
|
|
238
|
-
this.cachedLines = undefined;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** 清空全部派生缓存(`revision` 键与内容键:预览池 / 总览 / Tab 栏 / 左栏 / 菜单项;不触发重绘) */
|
|
242
|
-
private clearDerivedCaches(): void {
|
|
243
|
-
this.previewCache.clear();
|
|
244
|
-
this.summaryCachedLines = undefined;
|
|
245
|
-
this.tabCache = undefined;
|
|
246
|
-
this.leftColCache = undefined;
|
|
247
|
-
this.menuCache = undefined;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** 状态突变时失效整屏缓存并请求重绘(保留派生缓存池复用) */
|
|
251
|
-
private touch(): void {
|
|
252
|
-
this.clearFrameCaches();
|
|
253
|
-
this.tui.requestRender();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** 作答变更后的统一失效入口:模型已推进 revision,此处清空全部派生缓存 */
|
|
257
|
-
private touchDerived(): void {
|
|
258
|
-
this.clearDerivedCaches();
|
|
259
|
-
this.touch();
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Component 接口约定:宿主重绘上下文(主题切换 / 需从头重绘)时清空全部渲染缓存
|
|
264
|
-
* 终端尺寸变化不调用本方法(宿主 resize 仅 `requestRender`),由整屏缓存键绑定列宽与行数自然失效
|
|
265
|
-
*/
|
|
266
|
-
invalidate(): void {
|
|
267
|
-
this.touchDerived();
|
|
268
|
-
this.editor.invalidate();
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** 清理临时状态定时器(清空引用以便后续 setStatus 重新计时) */
|
|
272
|
-
private clearStatusTimer(): void {
|
|
273
|
-
if (this.statusTimer) {
|
|
274
|
-
clearTimeout(this.statusTimer);
|
|
275
|
-
this.statusTimer = null;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* 触发底部临时状态通知
|
|
281
|
-
* @param msg - 提示文案(取自 `TEXTS.status`,不做控制字符清洗)
|
|
282
|
-
*/
|
|
283
|
-
private setStatus(msg: string): void {
|
|
284
|
-
this.statusMessage = msg;
|
|
285
|
-
this.clearStatusTimer();
|
|
286
|
-
this.statusTimer = setTimeout(() => {
|
|
287
|
-
this.statusMessage = null;
|
|
288
|
-
this.statusTimer = null;
|
|
289
|
-
this.touch();
|
|
290
|
-
}, LAYOUT_CONFIG.statusDurationMs);
|
|
291
|
-
this.touch();
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* 终结作答流程并触发 `done`(至多投递一次)
|
|
296
|
-
* `isFinished` 单守卫防止重复投递;不以 `disposed` 阻断(资源已回收但 `done` 未投递时仍须结算),跳转入 {@link dispose}
|
|
297
|
-
* @param cancelled - 是否用户主动取消
|
|
298
|
-
*/
|
|
299
|
-
finish(cancelled: boolean): void {
|
|
300
|
-
if (this.isFinished) return;
|
|
301
|
-
this.isFinished = true;
|
|
302
|
-
this.dispose();
|
|
303
|
-
|
|
304
|
-
this.done({
|
|
305
|
-
formTitle: this.model.formTitle,
|
|
306
|
-
formDescription: this.model.formDescription,
|
|
307
|
-
questions: this.model.questions,
|
|
308
|
-
answers: cancelled ? [] : this.model.compileAnswers(),
|
|
309
|
-
cancelled,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Tab 总页数(题目页 + 总览提交页) */
|
|
314
|
-
private get totalTabs(): number {
|
|
315
|
-
return this.model.questionCount + 1;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** 当前 Tab 是否为答卷总览提交页 */
|
|
319
|
-
private get isSubmitTab(): boolean {
|
|
320
|
-
return this.currentTab === this.model.questionCount;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** 终端可视行数(`tui.terminal.rows` 不可用或非正数时兜底 24) */
|
|
324
|
-
private get terminalRows(): number {
|
|
325
|
-
const rows = this.tui.terminal?.rows;
|
|
326
|
-
return typeof rows === "number" && rows > 0 ? rows : 24;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** 总览页视口行数上限(随终端高度自适应;区间 `[4, LAYOUT_CONFIG.summaryPageSize]`) */
|
|
330
|
-
private get summaryPageSize(): number {
|
|
331
|
-
return clamp(this.terminalRows - 10, 4, LAYOUT_CONFIG.summaryPageSize);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** 双栏右侧预览视口行数上限(区间 `[6, LAYOUT_CONFIG.splitPreviewHeight]`) */
|
|
335
|
-
private get splitPreviewHeight(): number {
|
|
336
|
-
return clamp(this.terminalRows - 14, 6, LAYOUT_CONFIG.splitPreviewHeight);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** 单栏内联预览视口行数上限(区间 `[3, LAYOUT_CONFIG.singlePreviewHeight]`) */
|
|
340
|
-
private get singlePreviewHeight(): number {
|
|
341
|
-
return clamp(this.terminalRows - 18, 3, LAYOUT_CONFIG.singlePreviewHeight);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* 当前题目的菜单项
|
|
346
|
-
* @returns 菜单项数组;按 `(题号, revision)` 记忆化,命中时零分配复用
|
|
347
|
-
*/
|
|
348
|
-
private getMenuItems(): MenuItem[] {
|
|
349
|
-
const cached = this.menuCache;
|
|
350
|
-
if (cached && cached.tab === this.currentTab && cached.revision === this.model.revision) {
|
|
351
|
-
return cached.items;
|
|
352
|
-
}
|
|
353
|
-
const items = this.model.getMenuItems(this.currentTab);
|
|
354
|
-
this.menuCache = { tab: this.currentTab, revision: this.model.revision, items };
|
|
355
|
-
return items;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* 当前聚焦菜单项
|
|
360
|
-
* @returns 越界安全查询结果;无菜单项或索引越界时为 undefined
|
|
361
|
-
*/
|
|
362
|
-
private currentMenuItem(): MenuItem | undefined {
|
|
363
|
-
return this.getMenuItems()[this.currentOptionIndex];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* 切换题目 / 总览页 Tab,重置滚动位移并按 `cursorByTab` 恢复光标记忆
|
|
368
|
-
* @param newTab - 目标 Tab 索引(0-based;questionCount 为总览页;同页为无操作)
|
|
369
|
-
*/
|
|
370
|
-
private switchTab(newTab: number): void {
|
|
371
|
-
if (newTab === this.currentTab) return;
|
|
372
|
-
this.currentTab = newTab;
|
|
373
|
-
this.previewScrollOffset = 0;
|
|
374
|
-
this.summaryScrollOffset = 0;
|
|
375
|
-
this.listScrollOffset = 0;
|
|
376
|
-
this.menuCache = undefined;
|
|
377
|
-
|
|
378
|
-
if (!this.isSubmitTab) {
|
|
379
|
-
const st = this.model.getState(this.currentTab);
|
|
380
|
-
this.currentOptionIndex = this.cursorByTab.get(this.currentTab) ?? st.selectedIndex ?? 0;
|
|
381
|
-
this.adjustScrollForCurrentOption();
|
|
382
|
-
} else {
|
|
383
|
-
this.currentOptionIndex = 0;
|
|
384
|
-
}
|
|
385
|
-
this.touch();
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** 收敛光标至合法区间并同步左栏滚动窗口,保证聚焦项可见(菜单项为空时直接返回) */
|
|
389
|
-
private adjustScrollForCurrentOption(): void {
|
|
390
|
-
const count = this.getMenuItems().length;
|
|
391
|
-
if (count === 0) return;
|
|
392
|
-
this.currentOptionIndex = clamp(this.currentOptionIndex, 0, count - 1);
|
|
393
|
-
|
|
394
|
-
if (this.currentOptionIndex < this.listScrollOffset) {
|
|
395
|
-
this.listScrollOffset = this.currentOptionIndex;
|
|
396
|
-
} else if (this.currentOptionIndex >= this.listScrollOffset + LAYOUT_CONFIG.visibleOptionCount) {
|
|
397
|
-
this.listScrollOffset = this.currentOptionIndex - LAYOUT_CONFIG.visibleOptionCount + 1;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* 移动菜单光标并同步滚动窗口,重置预览位移
|
|
403
|
-
* @param target - 绝对索引或 `{ delta }` 相对位移(越界由 clamp 收敛,菜单为空时无操作)
|
|
404
|
-
*/
|
|
405
|
-
private moveCursor(target: number | { delta: number }): void {
|
|
406
|
-
const count = this.getMenuItems().length;
|
|
407
|
-
if (count === 0) return;
|
|
408
|
-
const next = typeof target === "number" ? target : this.currentOptionIndex + target.delta;
|
|
409
|
-
const clamped = clamp(next, 0, count - 1);
|
|
410
|
-
if (clamped === this.currentOptionIndex) return;
|
|
411
|
-
|
|
412
|
-
this.currentOptionIndex = clamped;
|
|
413
|
-
this.cursorByTab.set(this.currentTab, clamped);
|
|
414
|
-
this.previewScrollOffset = 0;
|
|
415
|
-
this.adjustScrollForCurrentOption();
|
|
416
|
-
this.touch();
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* 当前光标默认的编辑目标
|
|
421
|
-
* @returns 聚焦预设选项时为该选项,否则为题目整体
|
|
422
|
-
*/
|
|
423
|
-
private getEditTargetForCurrentSelection(): EditTarget {
|
|
424
|
-
const currentItem = this.currentMenuItem();
|
|
425
|
-
if (currentItem?.type === "option" && currentItem.optionIndex !== undefined) {
|
|
426
|
-
return { type: "option", optionIndex: currentItem.optionIndex };
|
|
427
|
-
}
|
|
428
|
-
return { type: "question" };
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* 打开补充说明编辑器(选项专属或题目整体)
|
|
433
|
-
* @param target - 编辑目标;缺省时按当前聚焦项推导(题目缺失或禁用补充说明时无操作)
|
|
434
|
-
*/
|
|
435
|
-
private openCustomEditor(target?: EditTarget): void {
|
|
436
|
-
const q = this.model.getQuestion(this.currentTab);
|
|
437
|
-
if (!q || !q.allowCustom) return;
|
|
438
|
-
|
|
439
|
-
const st = this.model.getState(this.currentTab);
|
|
440
|
-
const resolvedTarget = target ?? this.getEditTargetForCurrentSelection();
|
|
441
|
-
this.editTarget = resolvedTarget;
|
|
442
|
-
this.inputMode = true;
|
|
443
|
-
this.editorEscArmed = false;
|
|
444
|
-
|
|
445
|
-
const initialText =
|
|
446
|
-
resolvedTarget.type === "option" ? (st.optionNotes.get(resolvedTarget.optionIndex) ?? "") : (st.customText ?? "");
|
|
447
|
-
|
|
448
|
-
this.editor.setText(initialText);
|
|
449
|
-
this.editor.focused = this._focused;
|
|
450
|
-
this.touch();
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** 关闭编辑器并还原非输入模式状态(清空缓冲与焦点;幂等) */
|
|
454
|
-
private closeEditor(): void {
|
|
455
|
-
this.inputMode = false;
|
|
456
|
-
this.editorEscArmed = false;
|
|
457
|
-
this.editTarget = undefined;
|
|
458
|
-
this.editor.focused = false;
|
|
459
|
-
this.editor.setText("");
|
|
460
|
-
this.touch();
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* 确认当前题并跳转下一题 / 总览页(聚焦预设选项时先经模型写入选中)
|
|
465
|
-
* @param currentItem - 聚焦菜单项
|
|
466
|
-
* @param q - 当前题目(未达提交下限时仅提示状态栏,不切页)
|
|
467
|
-
*/
|
|
468
|
-
private confirmAndAdvance(currentItem: MenuItem, q: NormalizedQuestion): void {
|
|
469
|
-
if (currentItem.type === "option" && currentItem.optionIndex !== undefined) {
|
|
470
|
-
this.model.commitOption(this.currentTab, currentItem.optionIndex);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
if (this.model.isQuestionAnswered(this.currentTab)) {
|
|
474
|
-
this.switchTab(this.currentTab < this.model.questionCount - 1 ? this.currentTab + 1 : this.model.questionCount);
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
this.setStatus(q.multiSelect ? TEXTS.status.minSelectRequired(q.minSelect) : TEXTS.status.pleaseAnswerBeforeSubmit);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// ===== 键盘输入路由与快捷键体系 =====
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* Component 接口约定:键盘事件三级路由(输入模式 → 全局键 → 分视图)
|
|
484
|
-
* Esc 与 Ctrl+C 同为取消键(宿主不会自行中止工具信号):常规模式直接 `finish(true)`,输入模式下编辑器非空时首次仅武装丢弃确认
|
|
485
|
-
* @param data - 终端按键序列
|
|
486
|
-
*/
|
|
487
|
-
handleInput(data: string): void {
|
|
488
|
-
// 0. 已终结/已卸载的组件忽略一切迟到输入(done 回调到宿主卸载组件之间存在窗口)
|
|
489
|
-
if (this.isFinished || this.disposed) return;
|
|
490
|
-
|
|
491
|
-
const isCancelKey = matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"));
|
|
492
|
-
|
|
493
|
-
// 1. 输入模式委托内置 Editor;Esc/Ctrl+C 二次确认防止误触
|
|
494
|
-
if (this.inputMode) {
|
|
495
|
-
if (isCancelKey) {
|
|
496
|
-
if (this.editor.getText() !== "" && !this.editorEscArmed) {
|
|
497
|
-
this.editorEscArmed = true;
|
|
498
|
-
this.setStatus(TEXTS.status.editorDiscardConfirm);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
this.closeEditor();
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
if (this.editorEscArmed) {
|
|
505
|
-
// 继续输入视为放弃丢弃意图,解除武装并照常透传
|
|
506
|
-
this.editorEscArmed = false;
|
|
507
|
-
this.statusMessage = null;
|
|
508
|
-
}
|
|
509
|
-
this.editor.handleInput(data);
|
|
510
|
-
this.touch();
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// 2. Esc / Ctrl+C 退出取消
|
|
515
|
-
if (isCancelKey) {
|
|
516
|
-
this.finish(true);
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// 3. 数字键 1~9 快捷跳转题目(10 题以上经 Tab/hl 循环或总览页跳转)
|
|
521
|
-
if (/^[1-9]$/.test(data)) {
|
|
522
|
-
const targetIndex = parseInt(data, 10) - 1;
|
|
523
|
-
if (targetIndex < this.model.questionCount) {
|
|
524
|
-
this.switchTab(targetIndex);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// 4. Tab / 方向键左右 / h / l Vim 风格切换题目
|
|
530
|
-
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l") {
|
|
531
|
-
this.switchTab((this.currentTab + 1) % this.totalTabs);
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left) || data === "h") {
|
|
535
|
-
this.switchTab((this.currentTab - 1 + this.totalTabs) % this.totalTabs);
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// 5. 分视图输入路由
|
|
540
|
-
if (this.isSubmitTab) {
|
|
541
|
-
this.handleSummaryInput(data);
|
|
542
|
-
} else {
|
|
543
|
-
this.handleQuestionInput(data);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* 题目作答页常规键路由(移动 / 勾选 / 前进 / 补充说明;子路由均为先匹配先消费)
|
|
549
|
-
* @param data - 终端按键序列
|
|
550
|
-
*/
|
|
551
|
-
private handleQuestionInput(data: string): void {
|
|
552
|
-
const q = this.model.getQuestion(this.currentTab);
|
|
553
|
-
if (!q) return;
|
|
554
|
-
|
|
555
|
-
if (this.handleNoteShortcut(q, data)) return;
|
|
556
|
-
if (this.handlePreviewScroll(data)) return;
|
|
557
|
-
if (this.handleCursorNavigation(data)) return;
|
|
558
|
-
if (this.handleSelectAllShortcut(q, data)) return;
|
|
559
|
-
|
|
560
|
-
const currentItem = this.currentMenuItem();
|
|
561
|
-
if (!currentItem) return;
|
|
562
|
-
|
|
563
|
-
if (matchesKey(data, Key.ctrl("s"))) {
|
|
564
|
-
this.confirmAndAdvance(currentItem, q);
|
|
565
|
-
return;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
if (matchesKey(data, Key.space)) {
|
|
569
|
-
this.activateMenuItem(currentItem, q);
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
if (matchesKey(data, Key.enter)) {
|
|
574
|
-
if (currentItem.type === "custom_note") {
|
|
575
|
-
this.openCustomEditor({ type: "question" });
|
|
576
|
-
} else {
|
|
577
|
-
// 单选与多选统一:Enter = 选定并确认前进(多选未达下限时阻断),空格负责 toggle 勾选
|
|
578
|
-
this.confirmAndAdvance(currentItem, q);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* 补充说明快捷键路由(`n`/`e` 补充当前聚焦项、`N` 题目整体补充、`x`/Delete 清空)
|
|
585
|
-
* @param q - 当前题目
|
|
586
|
-
* @param data - 按键序列
|
|
587
|
-
* @returns 是否已消费该按键
|
|
588
|
-
*/
|
|
589
|
-
private handleNoteShortcut(q: NormalizedQuestion, data: string): boolean {
|
|
590
|
-
if (!q.allowCustom) return false;
|
|
591
|
-
|
|
592
|
-
// 1. 大写 'N':随时打开题目整体补充说明编辑器
|
|
593
|
-
if (data === "N") {
|
|
594
|
-
this.openCustomEditor({ type: "question" });
|
|
595
|
-
return true;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// 2. 小写 'n' / 'e':针对当前聚焦项打开编辑器(选项专属或题目整体)
|
|
599
|
-
if (data === "n" || data === "e") {
|
|
600
|
-
this.openCustomEditor();
|
|
601
|
-
return true;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// 3. 'x' / Delete:清空补充说明(选项专属优先,其次题目整体)
|
|
605
|
-
if (data === "x" || matchesKey(data, Key.delete)) {
|
|
606
|
-
const currentItem = this.currentMenuItem();
|
|
607
|
-
if (currentItem?.type === "option" && currentItem.optionIndex !== undefined) {
|
|
608
|
-
const optIdx = currentItem.optionIndex;
|
|
609
|
-
if (this.model.clearOptionNote(this.currentTab, optIdx)) {
|
|
610
|
-
this.setStatus(TEXTS.status.optionNoteCleared(optionLabelOf(q, optIdx)));
|
|
611
|
-
return true;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
if (this.model.clearQuestionNote(this.currentTab)) {
|
|
615
|
-
this.setStatus(TEXTS.status.noteCleared);
|
|
616
|
-
return true;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* 预览区垂直滚动键(PgUp / PgDn / 方括号 / Alt+上下)
|
|
625
|
-
* 滚动位移归一化归属输入路径:以渲染帧写入的 {@link previewViewportObservation} 为界经 `clampWindowOffset` 收敛(缺观察元数据时按单栏预览高度退化)
|
|
626
|
-
* @param data - 按键序列
|
|
627
|
-
* @returns 是否已消费该按键
|
|
628
|
-
*/
|
|
629
|
-
private handlePreviewScroll(data: string): boolean {
|
|
630
|
-
let step = 0;
|
|
631
|
-
if (matchesKey(data, Key.pageUp) || data === "[" || matchesKey(data, Key.alt("up"))) {
|
|
632
|
-
step = -LAYOUT_CONFIG.previewScrollStep;
|
|
633
|
-
} else if (matchesKey(data, Key.pageDown) || data === "]" || matchesKey(data, Key.alt("down"))) {
|
|
634
|
-
step = LAYOUT_CONFIG.previewScrollStep;
|
|
635
|
-
}
|
|
636
|
-
if (step === 0) return false;
|
|
637
|
-
const observation = this.previewViewportObservation;
|
|
638
|
-
const viewHeight = observation?.viewHeight ?? this.singlePreviewHeight;
|
|
639
|
-
const total = observation?.total ?? 0;
|
|
640
|
-
this.previewScrollOffset = clampWindowOffset(this.previewScrollOffset + step, total, viewHeight);
|
|
641
|
-
this.touch();
|
|
642
|
-
return true;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* 选项菜单光标移动键(↑↓ / jk / Home / End)
|
|
647
|
-
* @param data - 按键序列
|
|
648
|
-
* @returns 是否已消费该按键
|
|
649
|
-
*/
|
|
650
|
-
private handleCursorNavigation(data: string): boolean {
|
|
651
|
-
if (matchesKey(data, Key.up) || data === "k") {
|
|
652
|
-
this.moveCursor({ delta: -1 });
|
|
653
|
-
return true;
|
|
654
|
-
}
|
|
655
|
-
if (matchesKey(data, Key.down) || data === "j") {
|
|
656
|
-
this.moveCursor({ delta: 1 });
|
|
657
|
-
return true;
|
|
658
|
-
}
|
|
659
|
-
if (matchesKey(data, Key.home)) {
|
|
660
|
-
this.moveCursor(0);
|
|
661
|
-
return true;
|
|
662
|
-
}
|
|
663
|
-
if (matchesKey(data, Key.end)) {
|
|
664
|
-
this.moveCursor(Number.MAX_SAFE_INTEGER);
|
|
665
|
-
return true;
|
|
666
|
-
}
|
|
667
|
-
return false;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* 多选模式下 `a` 键全选 / 清空已选项
|
|
672
|
-
* @param q - 当前题目
|
|
673
|
-
* @param data - 按键序列
|
|
674
|
-
* @returns 是否已消费该按键
|
|
675
|
-
*/
|
|
676
|
-
private handleSelectAllShortcut(q: NormalizedQuestion, data: string): boolean {
|
|
677
|
-
if (!q.multiSelect || data !== "a") return false;
|
|
678
|
-
const result = this.model.selectAllOrClear(this.currentTab);
|
|
679
|
-
if (result === "filled" && q.maxSelect < q.options.length) {
|
|
680
|
-
this.setStatus(TEXTS.status.selectedUpToLimit(q.maxSelect));
|
|
681
|
-
} else {
|
|
682
|
-
this.touch();
|
|
683
|
-
}
|
|
684
|
-
return true;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* 触发聚焦条目主动作(唯一入口为空格键:选项勾选 / 打开补充说明 / 推进确认)
|
|
689
|
-
* @param currentItem - 聚焦菜单项
|
|
690
|
-
* @param q - 当前题目(选项命中上限时仅提示状态栏)
|
|
691
|
-
*/
|
|
692
|
-
private activateMenuItem(currentItem: MenuItem, q: NormalizedQuestion): void {
|
|
693
|
-
if (currentItem.type === "option" && currentItem.optionIndex !== undefined) {
|
|
694
|
-
if (this.model.toggleOption(this.currentTab, currentItem.optionIndex) === "limit") {
|
|
695
|
-
this.setStatus(TEXTS.status.maxSelectExceeded(q.maxSelect));
|
|
696
|
-
} else {
|
|
697
|
-
// 模型已变更而键盘路由不触发渲染,须立即重绘否则勾选滞后到下次按键
|
|
698
|
-
this.touch();
|
|
699
|
-
}
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
if (currentItem.type === "custom_note") {
|
|
703
|
-
this.openCustomEditor({ type: "question" });
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
this.confirmAndAdvance(currentItem, q);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* 总览页键路由(滚动与回车提交;`Ctrl+S` 同义提交)
|
|
711
|
-
* 空格更改为向下滚动(防止习惯性翻页误提交);位移归一化归属本路径,上界获取自 `summaryCachedLines` 总行数与 `summaryPageSize`,`End` 仅设置超大值,由后续输入收敛
|
|
712
|
-
* @param data - 按键序列
|
|
713
|
-
*/
|
|
714
|
-
private handleSummaryInput(data: string): void {
|
|
715
|
-
if (matchesKey(data, Key.enter) || matchesKey(data, Key.ctrl("s"))) {
|
|
716
|
-
if (this.model.areAllQuestionsAnswered()) {
|
|
717
|
-
this.finish(false);
|
|
718
|
-
} else {
|
|
719
|
-
const firstUnfinished = this.model.questions.findIndex((_, idx) => !this.model.isQuestionAnswered(idx));
|
|
720
|
-
if (firstUnfinished !== -1) {
|
|
721
|
-
this.switchTab(firstUnfinished);
|
|
722
|
-
const unfQ = this.model.getQuestion(firstUnfinished);
|
|
723
|
-
if (unfQ) {
|
|
724
|
-
this.setStatus(
|
|
725
|
-
unfQ.multiSelect ? TEXTS.status.minSelectRequired(unfQ.minSelect) : TEXTS.status.pleaseAnswerBeforeSubmit,
|
|
726
|
-
);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
if (matchesKey(data, Key.home)) {
|
|
733
|
-
this.summaryScrollOffset = 0;
|
|
734
|
-
this.touch();
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
if (matchesKey(data, Key.end)) {
|
|
738
|
-
this.summaryScrollOffset = Number.MAX_SAFE_INTEGER;
|
|
739
|
-
this.touch();
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
let step = this.summaryScrollStep(data);
|
|
744
|
-
if (step === 0 && matchesKey(data, Key.space)) step = LAYOUT_CONFIG.summaryScrollStep;
|
|
745
|
-
if (step === 0) return;
|
|
746
|
-
const total = this.summaryCachedLines?.lines.length ?? 0;
|
|
747
|
-
const pageSize = this.summaryPageSize;
|
|
748
|
-
// 先归一化过期位移(End 的超大值 / 题目变更后总行数变短)再叠加步进,保证 End 后按 ↑ 能回退
|
|
749
|
-
const base = clampWindowOffset(this.summaryScrollOffset, total, pageSize);
|
|
750
|
-
this.summaryScrollOffset = clampWindowOffset(base + step, total, pageSize);
|
|
751
|
-
this.touch();
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* 计算总览页滚动步长
|
|
756
|
-
* @param data - 按键序列
|
|
757
|
-
* @returns 行位移(正下负上,0 为无动作)
|
|
758
|
-
*/
|
|
759
|
-
private summaryScrollStep(data: string): number {
|
|
760
|
-
if (matchesKey(data, Key.pageUp) || data === "[" || matchesKey(data, Key.alt("up")))
|
|
761
|
-
return -LAYOUT_CONFIG.summaryScrollStep;
|
|
762
|
-
if (matchesKey(data, Key.pageDown) || data === "]" || matchesKey(data, Key.alt("down")))
|
|
763
|
-
return LAYOUT_CONFIG.summaryScrollStep;
|
|
764
|
-
if (matchesKey(data, Key.up) || data === "k") return -1;
|
|
765
|
-
if (matchesKey(data, Key.down) || data === "j") return 1;
|
|
766
|
-
return 0;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// ===== 视图渲染层 =====
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Component 接口约定:渲染整屏行(带整屏缓存)
|
|
773
|
-
* 缓存键绑定真实列宽、布局列宽与终端行数(窄于 20 列时二者截断范围不同,仅绑定 layoutWidth 会串帧;行数变化必须整体重新计算);本方法及可达被调函数绝不写入语义状态,仅刷新透明备忘 / 观察缓存
|
|
774
|
-
* @param width - 终端真实可视宽度(布局宽度下限 20 列)
|
|
775
|
-
* @returns 不超过 width 的终端行数组(命中缓存时直接返回)
|
|
776
|
-
*/
|
|
777
|
-
render(width: number): string[] {
|
|
778
|
-
const layoutWidth = Math.max(20, width);
|
|
779
|
-
const renderHeight = this.terminalRows;
|
|
780
|
-
if (
|
|
781
|
-
this.cachedLines &&
|
|
782
|
-
this.cachedWidth === width &&
|
|
783
|
-
this.cachedLayoutWidth === layoutWidth &&
|
|
784
|
-
this.cachedHeight === renderHeight
|
|
785
|
-
) {
|
|
786
|
-
return this.cachedLines;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const lines: string[] = [];
|
|
790
|
-
|
|
791
|
-
// 1. 顶部装饰线
|
|
792
|
-
pushFrameRule(lines, this.theme, layoutWidth);
|
|
793
|
-
|
|
794
|
-
// 2. Tab 导航栏(块级缓存,整块由 view-rows 层预计算)
|
|
795
|
-
const tabCacheKey = `${this.currentTab}_${this.model.revision}_${layoutWidth}`;
|
|
796
|
-
if (!this.tabCache || this.tabCache.key !== tabCacheKey) {
|
|
797
|
-
this.tabCache = { key: tabCacheKey, lines: buildTabLines(this.theme, this.model, this.currentTab, layoutWidth) };
|
|
798
|
-
}
|
|
799
|
-
lines.push(...this.tabCache.lines);
|
|
800
|
-
|
|
801
|
-
if (this.isSubmitTab) {
|
|
802
|
-
this.renderSubmitSummary(lines, layoutWidth);
|
|
803
|
-
} else if (this.inputMode) {
|
|
804
|
-
this.renderInputMode(lines, layoutWidth);
|
|
805
|
-
} else {
|
|
806
|
-
this.renderQuestionView(lines, layoutWidth);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// 3. 最终行宽保护(按调用方真实列宽截断)与缓存写入
|
|
810
|
-
this.cachedWidth = width;
|
|
811
|
-
this.cachedLayoutWidth = layoutWidth;
|
|
812
|
-
this.cachedHeight = renderHeight;
|
|
813
|
-
this.cachedLines = lines.map((line) => safeLine(line, width));
|
|
814
|
-
return this.cachedLines;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/**
|
|
818
|
-
* 原地追加底部状态通知行(无文案时不追加,仅包含通知行与前面的空行)
|
|
819
|
-
* @param lines - 输出行数组(原地追加)
|
|
820
|
-
*/
|
|
821
|
-
private pushStatusMessage(lines: string[]): void {
|
|
822
|
-
if (!this.statusMessage) return;
|
|
823
|
-
lines.push("");
|
|
824
|
-
lines.push(` ${this.theme.fg("warning", this.statusMessage)}`);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
/**
|
|
828
|
-
* 渲染答卷总览与确认提交页(完成状态 / 已选项 / 补充说明 / 提交指引)
|
|
829
|
-
* 内容行由 `view-summary.ts` 构建并按 `(列宽, revision)` 缓存于 `summaryCachedLines`,本方法只做 O(1) 切片;位移在本地收敛,绝不写回 `summaryScrollOffset`(写入只发生在输入路径)
|
|
830
|
-
* @param lines - 输出行数组
|
|
831
|
-
* @param renderWidth - 终端布局列宽
|
|
832
|
-
*/
|
|
833
|
-
private renderSubmitSummary(lines: string[], renderWidth: number): void {
|
|
834
|
-
lines.push(this.theme.fg("accent", this.theme.bold(TEXTS.summary.title)));
|
|
835
|
-
lines.push("");
|
|
836
|
-
|
|
837
|
-
const contentWidth = renderWidth - 2;
|
|
838
|
-
if (
|
|
839
|
-
!this.summaryCachedLines ||
|
|
840
|
-
this.summaryCachedLines.width !== contentWidth ||
|
|
841
|
-
this.summaryCachedLines.revision !== this.model.revision
|
|
842
|
-
) {
|
|
843
|
-
this.summaryCachedLines = {
|
|
844
|
-
width: contentWidth,
|
|
845
|
-
revision: this.model.revision,
|
|
846
|
-
lines: buildSubmitSummaryContentLines(this.model, this.theme, contentWidth),
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
const summaryContentLines = this.summaryCachedLines.lines;
|
|
851
|
-
const totalSummaryLines = summaryContentLines.length;
|
|
852
|
-
const pageSize = this.summaryPageSize;
|
|
853
|
-
const offset = clampWindowOffset(this.summaryScrollOffset, totalSummaryLines, pageSize);
|
|
854
|
-
|
|
855
|
-
if (offset > 0) {
|
|
856
|
-
lines.push(this.theme.fg("dim", TEXTS.summary.topMore(offset)));
|
|
857
|
-
}
|
|
858
|
-
for (const sl of windowSlice(summaryContentLines, offset, pageSize)) {
|
|
859
|
-
lines.push(sl);
|
|
860
|
-
}
|
|
861
|
-
const remaining = totalSummaryLines - (offset + pageSize);
|
|
862
|
-
if (remaining > 0) {
|
|
863
|
-
lines.push(this.theme.fg("dim", TEXTS.summary.bottomMore(remaining)));
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
lines.push("");
|
|
867
|
-
const prompt = this.model.areAllQuestionsAnswered()
|
|
868
|
-
? this.theme.fg("success", this.theme.bold(TEXTS.summary.allDonePrompt))
|
|
869
|
-
: this.theme.fg("warning", TEXTS.summary.pendingWarning);
|
|
870
|
-
pushWrapped(lines, prompt, contentWidth);
|
|
871
|
-
|
|
872
|
-
this.pushStatusMessage(lines);
|
|
873
|
-
|
|
874
|
-
lines.push("");
|
|
875
|
-
pushHelpFooter(lines, buildSummaryHelpParts(this.model), this.theme, renderWidth);
|
|
876
|
-
pushFrameRule(lines, this.theme, renderWidth);
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* 渲染补充说明编辑器全屏模式(标题 / 上下文背景 / 编辑框)
|
|
881
|
-
* @param lines - 输出行数组
|
|
882
|
-
* @param renderWidth - 终端可视宽度
|
|
883
|
-
*/
|
|
884
|
-
private renderInputMode(lines: string[], renderWidth: number): void {
|
|
885
|
-
const q = this.model.getQuestion(this.currentTab);
|
|
886
|
-
if (!q) return;
|
|
887
|
-
|
|
888
|
-
if (this.editTarget?.type === "option") {
|
|
889
|
-
const optIdx = this.editTarget.optionIndex;
|
|
890
|
-
const opt = q.options[optIdx];
|
|
891
|
-
const optLabel = optionLabelOf(q, optIdx);
|
|
892
|
-
const placeholder = opt?.customPlaceholder ?? TEXTS.fallbacks.defaultOptionCustomPlaceholder;
|
|
893
|
-
|
|
894
|
-
lines.push(this.theme.fg("accent", this.theme.bold(TEXTS.editor.optionTitle(q.title, optLabel))));
|
|
895
|
-
if (opt?.description) {
|
|
896
|
-
pushWrapped(lines, this.theme.fg("muted", opt.description), renderWidth, " ");
|
|
897
|
-
}
|
|
898
|
-
lines.push("");
|
|
899
|
-
lines.push(this.theme.fg("accent", ` ${placeholder}`));
|
|
900
|
-
} else {
|
|
901
|
-
lines.push(this.theme.fg("accent", this.theme.bold(TEXTS.editor.title(q.title))));
|
|
902
|
-
if (q.description) {
|
|
903
|
-
pushWrapped(lines, this.theme.fg("muted", q.description), renderWidth, " ");
|
|
904
|
-
}
|
|
905
|
-
lines.push("");
|
|
906
|
-
lines.push(this.theme.fg("accent", ` ${q.customPlaceholder}`));
|
|
907
|
-
}
|
|
908
|
-
lines.push("");
|
|
909
|
-
|
|
910
|
-
for (const el of this.editor.render(Math.max(10, renderWidth - 4))) {
|
|
911
|
-
lines.push(` ${el}`);
|
|
912
|
-
}
|
|
913
|
-
lines.push("");
|
|
914
|
-
lines.push(this.theme.fg("dim", TEXTS.editor.footerHelp));
|
|
915
|
-
pushFrameRule(lines, this.theme, renderWidth);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* 渲染常规题目作答视图(自适应双栏分屏或单栏紧凑列表)
|
|
920
|
-
* @param lines - 输出行数组
|
|
921
|
-
* @param renderWidth - 终端可视宽度
|
|
922
|
-
*/
|
|
923
|
-
private renderQuestionView(lines: string[], renderWidth: number): void {
|
|
924
|
-
const q = this.model.getQuestion(this.currentTab);
|
|
925
|
-
if (!q) return;
|
|
926
|
-
|
|
927
|
-
const st = this.model.getState(this.currentTab);
|
|
928
|
-
const items = this.getMenuItems();
|
|
929
|
-
|
|
930
|
-
// 1. 题目标签与标题
|
|
931
|
-
const modeTag = q.multiSelect
|
|
932
|
-
? this.theme.fg("warning", TEXTS.view.modeMulti(q.minSelect, q.maxSelect, q.options.length, q.allowEmpty))
|
|
933
|
-
: this.theme.fg("accent", TEXTS.view.modeSingle);
|
|
934
|
-
pushWrapped(lines, ` ${modeTag} ${this.theme.bold(q.title)}`, renderWidth);
|
|
935
|
-
|
|
936
|
-
if (q.description) {
|
|
937
|
-
pushWrapped(lines, this.theme.fg("muted", q.description), renderWidth, " ");
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
// 2. 补充说明横幅(题目整体补充 + 当前聚焦选项专属补充)
|
|
941
|
-
if (st.customText?.trim()) {
|
|
942
|
-
pushWrapped(lines, this.theme.fg("success", TEXTS.view.attachedNoteBanner(st.customText)), renderWidth, " ");
|
|
943
|
-
}
|
|
944
|
-
const currentItem = this.currentMenuItem();
|
|
945
|
-
if (currentItem?.type === "option" && currentItem.optionIndex !== undefined) {
|
|
946
|
-
const optNote = st.optionNotes.get(currentItem.optionIndex)?.trim();
|
|
947
|
-
if (optNote) {
|
|
948
|
-
const optLabel = optionLabelOf(q, currentItem.optionIndex);
|
|
949
|
-
pushWrapped(
|
|
950
|
-
lines,
|
|
951
|
-
this.theme.fg("warning", TEXTS.view.optionAttachedNoteBanner(optLabel, optNote)),
|
|
952
|
-
renderWidth,
|
|
953
|
-
" ",
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
lines.push("");
|
|
958
|
-
|
|
959
|
-
// 3. 智能分屏 / 紧凑单栏(富内容按可视列宽判定)
|
|
960
|
-
const hasRichContent = q.options.some(
|
|
961
|
-
(o, idx) => Boolean(o.preview) || visibleWidth(o.description ?? "") > 30 || Boolean(st.optionNotes.get(idx)),
|
|
962
|
-
);
|
|
963
|
-
if (renderWidth >= LAYOUT_CONFIG.splitViewMinWidth && hasRichContent) {
|
|
964
|
-
this.renderSplitView(lines, q, st, items, renderWidth);
|
|
965
|
-
} else {
|
|
966
|
-
this.pushListItems(lines, items, q, st, renderWidth - 2, hasRichContent);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// 4. 底部状态通知与按键提示
|
|
970
|
-
this.pushStatusMessage(lines);
|
|
971
|
-
|
|
972
|
-
lines.push("");
|
|
973
|
-
const helpParts: string[] = [TEXTS.help.switchQuestion];
|
|
974
|
-
if (this.model.questionCount > 1) {
|
|
975
|
-
helpParts.push(TEXTS.help.jumpQuestion);
|
|
976
|
-
}
|
|
977
|
-
helpParts.push(TEXTS.help.moveCursor);
|
|
978
|
-
if (currentItem?.type === "option") {
|
|
979
|
-
helpParts.push(TEXTS.help.editOptionNote);
|
|
980
|
-
helpParts.push(TEXTS.help.editQuestionNote);
|
|
981
|
-
} else {
|
|
982
|
-
helpParts.push(TEXTS.help.editNote);
|
|
983
|
-
}
|
|
984
|
-
const optHasNote =
|
|
985
|
-
currentItem?.type === "option" &&
|
|
986
|
-
currentItem.optionIndex !== undefined &&
|
|
987
|
-
st.optionNotes.has(currentItem.optionIndex);
|
|
988
|
-
if (optHasNote || hasQuestionNote(st)) {
|
|
989
|
-
helpParts.push(TEXTS.help.clearNote);
|
|
990
|
-
}
|
|
991
|
-
helpParts.push(q.multiSelect ? TEXTS.help.toggleCheckMulti : TEXTS.help.selectOptionSingle);
|
|
992
|
-
if (q.multiSelect) {
|
|
993
|
-
helpParts.push(TEXTS.help.toggleAll);
|
|
994
|
-
helpParts.push(TEXTS.help.confirmQuestion);
|
|
995
|
-
}
|
|
996
|
-
if (hasRichContent) helpParts.push(TEXTS.help.scrollDraft);
|
|
997
|
-
helpParts.push(TEXTS.help.cancel);
|
|
998
|
-
|
|
999
|
-
pushHelpFooter(lines, helpParts, this.theme, renderWidth);
|
|
1000
|
-
pushFrameRule(lines, this.theme, renderWidth);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* 获取聚焦条目的预渲染完整内容行(`previewCache` 透明备忘缓存,跨条目 / 切换题目零重复解析)
|
|
1005
|
-
* @param item - 目标菜单项
|
|
1006
|
-
* @param customText - 题目整体补充说明
|
|
1007
|
-
* @param optionNote - 选项专属补充说明
|
|
1008
|
-
* @param targetWidth - 预览可用列宽
|
|
1009
|
-
* @returns 未切片的完整内容行数组;`item` 缺省时返回空数组
|
|
1010
|
-
*/
|
|
1011
|
-
private previewContentLines(
|
|
1012
|
-
item: MenuItem | undefined,
|
|
1013
|
-
customText: string | undefined,
|
|
1014
|
-
optionNote: string | undefined,
|
|
1015
|
-
targetWidth: number,
|
|
1016
|
-
): string[] {
|
|
1017
|
-
if (!item) return [];
|
|
1018
|
-
|
|
1019
|
-
// 定界拼接杜绝变长字段歧义(字段值均已剥离控制字符)
|
|
1020
|
-
const cacheKey = [
|
|
1021
|
-
this.currentTab,
|
|
1022
|
-
item.type,
|
|
1023
|
-
item.optionIndex ?? -1,
|
|
1024
|
-
targetWidth,
|
|
1025
|
-
customText ?? "",
|
|
1026
|
-
optionNote ?? "",
|
|
1027
|
-
].join("\x1f");
|
|
1028
|
-
|
|
1029
|
-
const cached = this.previewCache.get(cacheKey);
|
|
1030
|
-
if (cached) return cached;
|
|
1031
|
-
|
|
1032
|
-
const fullLines = buildFullPreviewContentLines(item, customText, optionNote, targetWidth, this.theme);
|
|
1033
|
-
// 超容量按插入序淘汰最旧条目(`keys().next()` 在 Map 非空时必然产出条目)
|
|
1034
|
-
if (this.previewCache.size >= PREVIEW_CACHE_CAPACITY) {
|
|
1035
|
-
const oldest = this.previewCache.keys().next();
|
|
1036
|
-
if (!oldest.done) this.previewCache.delete(oldest.value);
|
|
1037
|
-
}
|
|
1038
|
-
this.previewCache.set(cacheKey, fullLines);
|
|
1039
|
-
return fullLines;
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/**
|
|
1043
|
-
* 计算并切片本帧预览框行(纯计算,不写回语义状态)
|
|
1044
|
-
* 位移由 `previewScrollOffset` 与本帧总行数在本地收敛;本帧观察元数据写入 {@link previewViewportObservation} 供输入路径归一化位移
|
|
1045
|
-
* @param item - 目标菜单项
|
|
1046
|
-
* @param customText - 题目整体补充说明
|
|
1047
|
-
* @param optionNote - 选项专属补充说明
|
|
1048
|
-
* @param targetWidth - 预览可用列宽
|
|
1049
|
-
* @param viewHeight - 视口最大行数
|
|
1050
|
-
* @returns 本帧可视内容行(含底部滚动信息行)
|
|
1051
|
-
*/
|
|
1052
|
-
private buildPreviewBox(
|
|
1053
|
-
item: MenuItem | undefined,
|
|
1054
|
-
customText: string | undefined,
|
|
1055
|
-
optionNote: string | undefined,
|
|
1056
|
-
targetWidth: number,
|
|
1057
|
-
viewHeight: number,
|
|
1058
|
-
): string[] {
|
|
1059
|
-
const fullLines = this.previewContentLines(item, customText, optionNote, targetWidth);
|
|
1060
|
-
const total = fullLines.length;
|
|
1061
|
-
this.previewViewportObservation = { total, viewHeight };
|
|
1062
|
-
return this.slicePreviewBoxLines(
|
|
1063
|
-
fullLines,
|
|
1064
|
-
clampWindowOffset(this.previewScrollOffset, total, viewHeight),
|
|
1065
|
-
viewHeight,
|
|
1066
|
-
targetWidth,
|
|
1067
|
-
);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/**
|
|
1071
|
-
* 对预渲染完整内容行做 O(1) 滑动窗口切片并附加底部行号信息
|
|
1072
|
-
* @param fullLines - 已折行完整内容行
|
|
1073
|
-
* @param scrollOffset - 已由 {@link clampWindowOffset} 收敛的起始行位移
|
|
1074
|
-
* @param viewHeight - 视口最大行数
|
|
1075
|
-
* @param targetWidth - 预览可用列宽
|
|
1076
|
-
* @returns 视口内容行与滚动信息行
|
|
1077
|
-
*/
|
|
1078
|
-
private slicePreviewBoxLines(
|
|
1079
|
-
fullLines: readonly string[],
|
|
1080
|
-
scrollOffset: number,
|
|
1081
|
-
viewHeight: number,
|
|
1082
|
-
targetWidth: number,
|
|
1083
|
-
): string[] {
|
|
1084
|
-
const totalLines = fullLines.length;
|
|
1085
|
-
const offset = clampWindowOffset(scrollOffset, totalLines, viewHeight);
|
|
1086
|
-
const boxLines = windowSlice(fullLines, offset, viewHeight).map((line) => safeLine(line, targetWidth));
|
|
1087
|
-
|
|
1088
|
-
if (totalLines > viewHeight) {
|
|
1089
|
-
const scrollInfo = TEXTS.view.previewScrollInfo(
|
|
1090
|
-
offset + 1,
|
|
1091
|
-
Math.min(totalLines, offset + viewHeight),
|
|
1092
|
-
totalLines,
|
|
1093
|
-
);
|
|
1094
|
-
boxLines.push(safeLine(` ${this.theme.fg("dim", scrollInfo)}`, targetWidth));
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
return boxLines;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
/**
|
|
1101
|
-
* 渲染双栏视图(左选项菜单 + 右推演 / 分镜预览框)
|
|
1102
|
-
* @param lines - 输出行数组
|
|
1103
|
-
* @param q - 当前题目
|
|
1104
|
-
* @param st - 作答状态
|
|
1105
|
-
* @param items - 菜单项
|
|
1106
|
-
* @param renderWidth - 终端可用宽度(不足侧补空行至两栏等高)
|
|
1107
|
-
*/
|
|
1108
|
-
private renderSplitView(
|
|
1109
|
-
lines: string[],
|
|
1110
|
-
q: NormalizedQuestion,
|
|
1111
|
-
st: QuestionAnswerState,
|
|
1112
|
-
items: MenuItem[],
|
|
1113
|
-
renderWidth: number,
|
|
1114
|
-
): void {
|
|
1115
|
-
const leftWidth = clamp(
|
|
1116
|
-
Math.floor(renderWidth * LAYOUT_CONFIG.leftColRatio),
|
|
1117
|
-
LAYOUT_CONFIG.leftColMin,
|
|
1118
|
-
LAYOUT_CONFIG.leftColMax,
|
|
1119
|
-
);
|
|
1120
|
-
const rightWidth = Math.max(20, renderWidth - leftWidth - LAYOUT_CONFIG.dividerWidth);
|
|
1121
|
-
|
|
1122
|
-
const leftCacheKey = `${this.currentTab}_${this.currentOptionIndex}_${this.listScrollOffset}_${this.model.revision}_${leftWidth}`;
|
|
1123
|
-
let leftLines: string[];
|
|
1124
|
-
if (this.leftColCache && this.leftColCache.key === leftCacheKey) {
|
|
1125
|
-
leftLines = this.leftColCache.lines;
|
|
1126
|
-
} else {
|
|
1127
|
-
leftLines = [];
|
|
1128
|
-
this.pushListItems(leftLines, items, q, st, leftWidth, false);
|
|
1129
|
-
this.leftColCache = { key: leftCacheKey, lines: leftLines };
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
const currentItem = items[this.currentOptionIndex];
|
|
1133
|
-
const currentOptNote =
|
|
1134
|
-
currentItem?.type === "option" && currentItem.optionIndex !== undefined
|
|
1135
|
-
? st.optionNotes.get(currentItem.optionIndex)
|
|
1136
|
-
: undefined;
|
|
1137
|
-
|
|
1138
|
-
// 位移以本条目本帧总行数为界在本地收敛,绝不写回 previewScrollOffset
|
|
1139
|
-
const rightLines = this.buildPreviewBox(
|
|
1140
|
-
currentItem,
|
|
1141
|
-
st.customText,
|
|
1142
|
-
currentOptNote,
|
|
1143
|
-
rightWidth,
|
|
1144
|
-
this.splitPreviewHeight,
|
|
1145
|
-
);
|
|
1146
|
-
|
|
1147
|
-
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
1148
|
-
for (let r = 0; r < maxRows; r++) {
|
|
1149
|
-
const lText = leftLines[r] ?? "";
|
|
1150
|
-
const rText = rightLines[r] ?? "";
|
|
1151
|
-
lines.push(
|
|
1152
|
-
`${padToVisibleWidth(lText, leftWidth)}${this.theme.fg("dim", LAYOUT_CONFIG.dividerChar)}${safeLine(rText, rightWidth)}`,
|
|
1153
|
-
);
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* 遍历输出菜单项行,单栏模式下在聚焦项下方内联展开预览
|
|
1159
|
-
* @param lines - 输出行数组
|
|
1160
|
-
* @param items - 菜单项
|
|
1161
|
-
* @param q - 当前题目
|
|
1162
|
-
* @param st - 作答状态
|
|
1163
|
-
* @param rowWidth - 选项行可用列宽
|
|
1164
|
-
* @param expandFocused - 是否展开聚焦项预览(窗口外条目不访问)
|
|
1165
|
-
*/
|
|
1166
|
-
private pushListItems(
|
|
1167
|
-
lines: string[],
|
|
1168
|
-
items: MenuItem[],
|
|
1169
|
-
q: NormalizedQuestion,
|
|
1170
|
-
st: QuestionAnswerState,
|
|
1171
|
-
rowWidth: number,
|
|
1172
|
-
expandFocused: boolean,
|
|
1173
|
-
): void {
|
|
1174
|
-
if (this.listScrollOffset > 0) {
|
|
1175
|
-
lines.push(this.theme.fg("dim", TEXTS.view.listTopMore(this.listScrollOffset)));
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
const visibleSlice = items.slice(this.listScrollOffset, this.listScrollOffset + LAYOUT_CONFIG.visibleOptionCount);
|
|
1179
|
-
for (const [offset, item] of visibleSlice.entries()) {
|
|
1180
|
-
if (!item) continue;
|
|
1181
|
-
const globalIndex = this.listScrollOffset + offset;
|
|
1182
|
-
const isFocused = globalIndex === this.currentOptionIndex;
|
|
1183
|
-
|
|
1184
|
-
// 预设选项与操作区(补充说明/确认按钮)之间间隔一行
|
|
1185
|
-
const prevItem = items[globalIndex - 1];
|
|
1186
|
-
if (prevItem && prevItem.type === "option" && item.type !== "option") {
|
|
1187
|
-
lines.push("");
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
lines.push(...formatMenuItemRow(this.theme, item, isFocused, q, st, rowWidth));
|
|
1191
|
-
|
|
1192
|
-
// 单栏模式且具备富文本内容时,在聚焦项下方展开预览
|
|
1193
|
-
if (
|
|
1194
|
-
isFocused &&
|
|
1195
|
-
expandFocused &&
|
|
1196
|
-
(item.type !== "option" || item.description || item.preview || st.optionNotes.has(item.optionIndex ?? -1))
|
|
1197
|
-
) {
|
|
1198
|
-
lines.push("");
|
|
1199
|
-
const itemOptNote =
|
|
1200
|
-
item.type === "option" && item.optionIndex !== undefined ? st.optionNotes.get(item.optionIndex) : undefined;
|
|
1201
|
-
// 位移以本条目本帧实时总行数在本地收敛,绝不写回 previewScrollOffset
|
|
1202
|
-
const previewLines = this.buildPreviewBox(
|
|
1203
|
-
item,
|
|
1204
|
-
st.customText,
|
|
1205
|
-
itemOptNote,
|
|
1206
|
-
Math.max(10, rowWidth - 2),
|
|
1207
|
-
this.singlePreviewHeight,
|
|
1208
|
-
);
|
|
1209
|
-
for (const pl of previewLines) lines.push(pl);
|
|
1210
|
-
lines.push("");
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
const remainingBelow = items.length - (this.listScrollOffset + LAYOUT_CONFIG.visibleOptionCount);
|
|
1215
|
-
if (remainingBelow > 0) {
|
|
1216
|
-
lines.push(this.theme.fg("dim", TEXTS.view.listBottomMore(remainingBelow)));
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}
|