@zhushanwen/pi-ask-user 0.0.4 → 0.2.0
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/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +6 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/component-keymap.test.ts +522 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/e2e-harness.ts +1 -0
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +329 -25
- package/src/__tests__/question-view.test.ts +71 -53
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +19 -4
- package/src/__tests__/w2-draft-hint.test.ts +157 -0
- package/src/__tests__/w3-regression.test.ts +128 -0
- package/src/answer-format.ts +51 -0
- package/src/component.ts +132 -129
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +198 -72
- package/src/question-view.ts +81 -65
- package/src/submit-view.ts +19 -11
- package/src/types.ts +24 -2
- package/src/validate.ts +20 -1
package/src/component.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
// src/component.ts
|
|
2
|
-
import { type Component, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
deleteCharBeforeCursor,
|
|
6
|
+
handleEditorPaste,
|
|
7
|
+
insertAtCursor,
|
|
8
|
+
moveCursorEnd,
|
|
9
|
+
moveCursorHome,
|
|
10
|
+
moveCursorLeft,
|
|
11
|
+
moveCursorRight,
|
|
12
|
+
} from "./editor-ops";
|
|
4
13
|
import { allOptions, renderQuestionView } from "./question-view";
|
|
5
|
-
import { buildResult, renderSubmitView } from "./submit-view";
|
|
14
|
+
import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
|
|
6
15
|
import {
|
|
7
16
|
createQuestionState,
|
|
8
17
|
HEADER_MAX_CHARS,
|
|
@@ -32,7 +41,6 @@ export class AskUserComponent implements Component {
|
|
|
32
41
|
|
|
33
42
|
private states: QuestionState[];
|
|
34
43
|
private activeTab: number = 0;
|
|
35
|
-
private editorText: string = "";
|
|
36
44
|
|
|
37
45
|
/** Submit tab 上的左右焦点:默认 Submit;← / → 切换;Enter 触发当前项。
|
|
38
46
|
* 问题 tab 上无意义(仅视觉占位),不参与输入路由。 */
|
|
@@ -75,6 +83,13 @@ export class AskUserComponent implements Component {
|
|
|
75
83
|
this.cachedLines = undefined;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/** 状态变更后的标准后续:失效缓存 + 请求重绘。
|
|
87
|
+
* 消除散弹式修改——每个 mutation 方法末尾不再需要手写 invalidate + requestRender 两行。 */
|
|
88
|
+
private rerender(): void {
|
|
89
|
+
this.invalidate();
|
|
90
|
+
this.tui.requestRender();
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
// ── 渲染 ──
|
|
79
94
|
render(width: number): string[] {
|
|
80
95
|
if (this.cachedWidth === width && this.cachedLines) {
|
|
@@ -107,7 +122,7 @@ export class AskUserComponent implements Component {
|
|
|
107
122
|
} else {
|
|
108
123
|
const q = this.questions[this.activeTab]!;
|
|
109
124
|
const state = this.states[this.activeTab]!;
|
|
110
|
-
for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle
|
|
125
|
+
for (const line of renderQuestionView({ question: q, state, theme: t, width: innerWidth, isSingle: this.isSingle })) {
|
|
111
126
|
add(line);
|
|
112
127
|
}
|
|
113
128
|
}
|
|
@@ -115,7 +130,7 @@ export class AskUserComponent implements Component {
|
|
|
115
130
|
// 多问题:底部按钮栏(Submit / Cancel)。Submit tab 上不重复渲染(renderSubmitView 内嵌 focus 高亮)
|
|
116
131
|
if (!this.isSingle && this.activeTab < this.questions.length) {
|
|
117
132
|
inner.push("");
|
|
118
|
-
this.
|
|
133
|
+
add(renderButtonBar(this.theme, this.allConfirmed(), null));
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
// 用 box 边框包裹:每行 pad 到 innerWidth 后加 │ 左右边框
|
|
@@ -162,28 +177,6 @@ export class AskUserComponent implements Component {
|
|
|
162
177
|
add(truncateToWidth(parts.join(""), innerWidth));
|
|
163
178
|
}
|
|
164
179
|
|
|
165
|
-
/**
|
|
166
|
-
* 底部按钮栏:[ Submit ] [ Cancel ]。
|
|
167
|
-
* focus: null=纯展示(问题 tab),"submit"/"cancel"=高亮对应按钮(Submit tab)。
|
|
168
|
-
*/
|
|
169
|
-
private renderButtonBar(
|
|
170
|
-
innerWidth: number,
|
|
171
|
-
add: (s: string) => void,
|
|
172
|
-
focus: "submit" | "cancel" | null,
|
|
173
|
-
): void {
|
|
174
|
-
const t = this.theme;
|
|
175
|
-
const allDone = this.allConfirmed();
|
|
176
|
-
const isSubmit = focus === "submit";
|
|
177
|
-
const isCancel = focus === "cancel";
|
|
178
|
-
const submit = isSubmit
|
|
179
|
-
? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
|
|
180
|
-
: (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
|
|
181
|
-
const cancel = isCancel
|
|
182
|
-
? t.fg("accent", t.bold(" Cancel "))
|
|
183
|
-
: t.fg("muted", " Cancel ");
|
|
184
|
-
add(`${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
180
|
// ── 输入路由 ──
|
|
188
181
|
handleInput(data: string): void {
|
|
189
182
|
if (this._resolved) return;
|
|
@@ -194,8 +187,7 @@ export class AskUserComponent implements Component {
|
|
|
194
187
|
this.cancel();
|
|
195
188
|
} else {
|
|
196
189
|
this.pendingCancel = false;
|
|
197
|
-
this.
|
|
198
|
-
this.tui.requestRender();
|
|
190
|
+
this.rerender();
|
|
199
191
|
}
|
|
200
192
|
return;
|
|
201
193
|
}
|
|
@@ -215,15 +207,22 @@ export class AskUserComponent implements Component {
|
|
|
215
207
|
return;
|
|
216
208
|
}
|
|
217
209
|
|
|
210
|
+
// options mode → delegate to handleOptionsInput
|
|
211
|
+
this.handleOptionsInput(data, state, q);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* options 模式的输入处理(从 handleInput 拆出)。
|
|
216
|
+
* 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
|
|
217
|
+
*/
|
|
218
|
+
private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
|
|
218
219
|
// Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
|
|
219
220
|
if (matchesKey(data, "escape")) {
|
|
220
221
|
this.escBackOrConfirm();
|
|
221
222
|
return;
|
|
222
223
|
}
|
|
223
224
|
|
|
224
|
-
// ← / → 切换问题 tab(多问题,options
|
|
225
|
-
// 首个 ← 停在首个问题(不环绕)。Tab/Shift+Tab 不在问题 tab 切 tab——
|
|
226
|
-
// 留给 Submit tab 上的 Submit/Cancel 焦点切换。
|
|
225
|
+
// ← / → 切换问题 tab(多问题,options 模式)
|
|
227
226
|
if (!this.isSingle && matchesKey(data, "right")) {
|
|
228
227
|
this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
|
|
229
228
|
return;
|
|
@@ -235,29 +234,26 @@ export class AskUserComponent implements Component {
|
|
|
235
234
|
|
|
236
235
|
if (matchesKey(data, "up")) {
|
|
237
236
|
state.cursorIndex = Math.max(0, state.cursorIndex - 1);
|
|
238
|
-
this.
|
|
239
|
-
this.tui.requestRender();
|
|
237
|
+
this.rerender();
|
|
240
238
|
return;
|
|
241
239
|
}
|
|
242
240
|
if (matchesKey(data, "down")) {
|
|
243
241
|
const max = allOptions(q).length - 1;
|
|
244
242
|
state.cursorIndex = Math.min(max, state.cursorIndex + 1);
|
|
245
|
-
this.
|
|
246
|
-
this.tui.requestRender();
|
|
243
|
+
this.rerender();
|
|
247
244
|
return;
|
|
248
245
|
}
|
|
249
246
|
|
|
250
247
|
const opts = allOptions(q);
|
|
251
248
|
const onOther = state.cursorIndex === opts.length - 1;
|
|
252
249
|
|
|
253
|
-
// Other row → Enter opens freeform editor
|
|
254
|
-
// 单选/多选普通选项的 Enter 各自有不同语义(确认/加入选中),与 Other 互斥:
|
|
255
|
-
// onOther 时本分支先消费 Enter,下面多选/单选分支不再处理。
|
|
250
|
+
// Other row → Enter opens freeform editor
|
|
256
251
|
if (onOther && matchesKey(data, "enter")) {
|
|
252
|
+
state.savedOptionsCursorIndex = state.cursorIndex;
|
|
257
253
|
state.mode = "freeform";
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.
|
|
254
|
+
state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
|
|
255
|
+
state.cursorIndex = state.draftText.length;
|
|
256
|
+
this.rerender();
|
|
261
257
|
return;
|
|
262
258
|
}
|
|
263
259
|
|
|
@@ -267,10 +263,6 @@ export class AskUserComponent implements Component {
|
|
|
267
263
|
return;
|
|
268
264
|
}
|
|
269
265
|
if (matchesKey(data, "enter")) {
|
|
270
|
-
// Enter 先把光标所在的普通选项加入选中再确认,与单选分支(Enter 时
|
|
271
|
-
// selectedIndex = cursorIndex)保持一致。即使 freeTextValue 已存在
|
|
272
|
-
// (Other 录入),也尊重「在此选项上按 Enter 想选中它」的意图,避免
|
|
273
|
-
// 静默用旧 Other 文本确认。add 幂等,不会误取消已选项。
|
|
274
266
|
state.selectedIndices.add(state.cursorIndex);
|
|
275
267
|
this.afterConfirm(state, q);
|
|
276
268
|
return;
|
|
@@ -306,15 +298,13 @@ export class AskUserComponent implements Component {
|
|
|
306
298
|
// Esc → 回退到最后一个问题
|
|
307
299
|
if (matchesKey(data, "escape")) {
|
|
308
300
|
this.activeTab = this.questions.length - 1;
|
|
309
|
-
this.
|
|
310
|
-
this.tui.requestRender();
|
|
301
|
+
this.rerender();
|
|
311
302
|
return;
|
|
312
303
|
}
|
|
313
304
|
// Tab → Submit ↔ Cancel 循环切焦点(单键双向)
|
|
314
305
|
if (matchesKey(data, "tab")) {
|
|
315
306
|
this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
|
|
316
|
-
this.
|
|
317
|
-
this.tui.requestRender();
|
|
307
|
+
this.rerender();
|
|
318
308
|
return;
|
|
319
309
|
}
|
|
320
310
|
// Enter → 触发当前 focus
|
|
@@ -330,81 +320,97 @@ export class AskUserComponent implements Component {
|
|
|
330
320
|
}
|
|
331
321
|
|
|
332
322
|
private handleEditorInput(data: string, state: QuestionState, q: Question): void {
|
|
323
|
+
// parseKey 白名单拦截 — 替代旧的 matchesKey 散调 + 兜底 printable 遍历
|
|
324
|
+
const keyId = parseKey(data);
|
|
325
|
+
if (keyId !== undefined) {
|
|
326
|
+
this.handleEditorKey(data, keyId, state, q);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (handleEditorPaste(state, data)) this.rerender();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
|
|
333
|
+
* 其他 special key(功能键/modifier 组合)no-op。 */
|
|
334
|
+
private handleEditorKey(data: string, keyId: string, state: QuestionState, q: Question): void {
|
|
333
335
|
if (matchesKey(data, "escape")) {
|
|
334
|
-
|
|
335
|
-
// AC-17: Esc in comment = skip comment, advance (keep existing commentValue)
|
|
336
|
-
state.mode = "options";
|
|
337
|
-
this.editorText = "";
|
|
338
|
-
this.advance();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
// Esc in freeform editor = back to options (discard input)
|
|
342
|
-
state.mode = "options";
|
|
343
|
-
this.editorText = "";
|
|
344
|
-
this.invalidate();
|
|
345
|
-
this.tui.requestRender();
|
|
336
|
+
this.handleEditorEsc(state);
|
|
346
337
|
return;
|
|
347
338
|
}
|
|
348
339
|
if (matchesKey(data, "enter")) {
|
|
349
|
-
|
|
350
|
-
if (state.mode === "freeform") {
|
|
351
|
-
if (text) {
|
|
352
|
-
state.freeTextValue = text;
|
|
353
|
-
state.selectedIndex = null;
|
|
354
|
-
state.mode = "options";
|
|
355
|
-
this.editorText = "";
|
|
356
|
-
// freeform 保存后走 afterConfirm(可能进 comment 模式)
|
|
357
|
-
this.afterConfirm(state, q);
|
|
358
|
-
} else {
|
|
359
|
-
// FR-6: 空 Enter 仅清除 freeTextValue、关闭编辑器回选项列表,不含确认语义(不置 confirmed)
|
|
360
|
-
state.freeTextValue = null;
|
|
361
|
-
state.mode = "options";
|
|
362
|
-
this.editorText = "";
|
|
363
|
-
// 对齐 toggleIndex 守卫:清空后若全无答案,重置 confirmed,维持 confirmed ⟹ 有答案 不变式
|
|
364
|
-
if ((q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) && state.freeTextValue === null) {
|
|
365
|
-
state.confirmed = false;
|
|
366
|
-
}
|
|
367
|
-
this.invalidate();
|
|
368
|
-
this.tui.requestRender();
|
|
369
|
-
}
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
// comment mode:保存评论后直接前进(不再回头进 comment)
|
|
373
|
-
state.commentValue = text || null;
|
|
374
|
-
state.mode = "options";
|
|
375
|
-
this.editorText = "";
|
|
376
|
-
this.advance();
|
|
340
|
+
this.handleEditorEnter(state, q);
|
|
377
341
|
return;
|
|
378
342
|
}
|
|
379
343
|
if (matchesKey(data, "backspace")) {
|
|
380
|
-
|
|
381
|
-
this.invalidate();
|
|
382
|
-
this.tui.requestRender();
|
|
344
|
+
if (deleteCharBeforeCursor(state)) this.rerender();
|
|
383
345
|
return;
|
|
384
346
|
}
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
347
|
+
// 光标移动(4 方向 + home/end)
|
|
348
|
+
if (matchesKey(data, "left")) { moveCursorLeft(state); this.rerender(); return; }
|
|
349
|
+
if (matchesKey(data, "right")) { moveCursorRight(state); this.rerender(); return; }
|
|
350
|
+
if (matchesKey(data, "home")) { moveCursorHome(state); this.rerender(); return; }
|
|
351
|
+
if (matchesKey(data, "end")) { moveCursorEnd(state); this.rerender(); return; }
|
|
352
|
+
// 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
|
|
353
|
+
if (matchesKey(data, "space")) {
|
|
354
|
+
insertAtCursor(state, " ");
|
|
355
|
+
this.rerender();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
|
|
359
|
+
if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
|
|
360
|
+
insertAtCursor(state, keyId);
|
|
361
|
+
this.rerender();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
|
|
368
|
+
private handleEditorEsc(state: QuestionState): void {
|
|
369
|
+
if (state.mode === "comment") {
|
|
370
|
+
// comment Esc: skip comment, advance (keep existing commentValue)
|
|
371
|
+
state.mode = "options";
|
|
372
|
+
state.draftText = "";
|
|
373
|
+
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
374
|
+
this.advance();
|
|
375
|
+
return;
|
|
402
376
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
377
|
+
// freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
|
|
378
|
+
// so discarded drafts don't pollute the answer or trigger auto-confirm.
|
|
379
|
+
state.freeDraft = state.draftText || null;
|
|
380
|
+
state.mode = "options";
|
|
381
|
+
state.draftText = "";
|
|
382
|
+
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
383
|
+
this.rerender();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
|
|
387
|
+
private handleEditorEnter(state: QuestionState, q: Question): void {
|
|
388
|
+
const text = state.draftText.trim();
|
|
389
|
+
if (state.mode === "freeform") {
|
|
390
|
+
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
391
|
+
if (text) {
|
|
392
|
+
state.freeTextValue = text;
|
|
393
|
+
state.selectedIndex = null;
|
|
394
|
+
state.mode = "options";
|
|
395
|
+
state.draftText = "";
|
|
396
|
+
this.afterConfirm(state, q);
|
|
397
|
+
} else {
|
|
398
|
+
state.freeTextValue = null;
|
|
399
|
+
state.mode = "options";
|
|
400
|
+
state.draftText = "";
|
|
401
|
+
// freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
|
|
402
|
+
if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
|
|
403
|
+
state.confirmed = false;
|
|
404
|
+
}
|
|
405
|
+
this.rerender();
|
|
406
|
+
}
|
|
406
407
|
return;
|
|
407
408
|
}
|
|
409
|
+
state.commentValue = text || null;
|
|
410
|
+
state.mode = "options";
|
|
411
|
+
state.draftText = "";
|
|
412
|
+
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
413
|
+
this.advance();
|
|
408
414
|
}
|
|
409
415
|
|
|
410
416
|
private toggleIndex(state: QuestionState, index: number): void {
|
|
@@ -413,8 +419,7 @@ export class AskUserComponent implements Component {
|
|
|
413
419
|
if (state.selectedIndices.size === 0 && state.freeTextValue === null) {
|
|
414
420
|
state.confirmed = false;
|
|
415
421
|
}
|
|
416
|
-
this.
|
|
417
|
-
this.tui.requestRender();
|
|
422
|
+
this.rerender();
|
|
418
423
|
}
|
|
419
424
|
|
|
420
425
|
private autoConfirmIfAnswered(): void {
|
|
@@ -431,33 +436,29 @@ export class AskUserComponent implements Component {
|
|
|
431
436
|
private gotoTab(target: number): void {
|
|
432
437
|
this.autoConfirmIfAnswered();
|
|
433
438
|
this.activeTab = target;
|
|
434
|
-
this.
|
|
435
|
-
this.tui.requestRender();
|
|
439
|
+
this.rerender();
|
|
436
440
|
}
|
|
437
441
|
|
|
438
442
|
/** Esc 语义:有上一个 tab 则回退;已在首个(或单问题)则进入确认取消覆盖层。 */
|
|
439
443
|
private escBackOrConfirm(): void {
|
|
440
444
|
if (this.activeTab > 0) {
|
|
441
445
|
this.activeTab--;
|
|
442
|
-
this.
|
|
443
|
-
this.tui.requestRender();
|
|
446
|
+
this.rerender();
|
|
444
447
|
return;
|
|
445
448
|
}
|
|
446
449
|
this.pendingCancel = true;
|
|
447
|
-
this.
|
|
448
|
-
this.tui.requestRender();
|
|
450
|
+
this.rerender();
|
|
449
451
|
}
|
|
450
452
|
|
|
451
453
|
/** 选中确认后的处理:若 allowComment,进入评论模式(可重入编辑/清除已有评论);否则前进。 */
|
|
452
454
|
private afterConfirm(state: QuestionState, q: Question): void {
|
|
453
455
|
state.confirmed = true;
|
|
454
456
|
if (q.allowComment && state.mode !== "comment") {
|
|
455
|
-
|
|
456
|
-
// 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
|
|
457
|
+
state.savedOptionsCursorIndex = state.cursorIndex;
|
|
457
458
|
state.mode = "comment";
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
this.
|
|
459
|
+
state.draftText = state.commentValue ?? "";
|
|
460
|
+
state.cursorIndex = state.draftText.length;
|
|
461
|
+
this.rerender();
|
|
461
462
|
return;
|
|
462
463
|
}
|
|
463
464
|
this.advance();
|
|
@@ -473,8 +474,7 @@ export class AskUserComponent implements Component {
|
|
|
473
474
|
} else {
|
|
474
475
|
this.activeTab = this.questions.length;
|
|
475
476
|
}
|
|
476
|
-
this.
|
|
477
|
-
this.tui.requestRender();
|
|
477
|
+
this.rerender();
|
|
478
478
|
}
|
|
479
479
|
|
|
480
480
|
private submit(): void {
|
|
@@ -482,8 +482,11 @@ export class AskUserComponent implements Component {
|
|
|
482
482
|
this.done(buildResult(this.questions, this.states));
|
|
483
483
|
}
|
|
484
484
|
|
|
485
|
-
/** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12
|
|
485
|
+
/** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态)。
|
|
486
|
+
* 守卫在方法内:signal abort 可能在用户已 submit/cancel 后才触发,
|
|
487
|
+
* 此时必须 no-op,避免二次调 done(FR-12)。 */
|
|
486
488
|
cancel(): void {
|
|
489
|
+
if (this._resolved) return;
|
|
487
490
|
this._resolved = true;
|
|
488
491
|
this.done(null);
|
|
489
492
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/editor-ops.ts
|
|
2
|
+
// 文本编辑器的纯操作函数——操作 QuestionState.draftText/cursorIndex。
|
|
3
|
+
// 从 AskUserComponent 提取,使 component 聚焦于问卷交互(tab 导航/状态转换/渲染编排),
|
|
4
|
+
// 不再理解终端转义序列解析和 surrogate pair 细节。
|
|
5
|
+
//
|
|
6
|
+
// 所有函数直接修改传入的 state(mutation 风格,与调用方 component 的命令式风格一致)。
|
|
7
|
+
// 是否产生了变更由返回值表达,调用方据此决定是否 rerender。
|
|
8
|
+
|
|
9
|
+
import { isHighSurrogate, SURROGATE_PAIR_LEN, type QuestionState } from "./types";
|
|
10
|
+
|
|
11
|
+
// ── 编辑器纯操作 ──
|
|
12
|
+
|
|
13
|
+
// ── 编辑器纯操作 ──
|
|
14
|
+
|
|
15
|
+
/** 在光标处插入文本,光标前移 text.length。 */
|
|
16
|
+
export function insertAtCursor(state: QuestionState, text: string): void {
|
|
17
|
+
state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
|
|
18
|
+
state.cursorIndex += text.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 删除光标前一个 code point(surrogate pair 时删整个 code point)。
|
|
22
|
+
* 返回 true 表示有删除发生(调用方据此决定是否 invalidate)。 */
|
|
23
|
+
export function deleteCharBeforeCursor(state: QuestionState): boolean {
|
|
24
|
+
if (state.cursorIndex <= 0) return false;
|
|
25
|
+
const deleteCount = state.cursorIndex >= SURROGATE_PAIR_LEN && isHighSurrogate(state.draftText, state.cursorIndex - SURROGATE_PAIR_LEN) ? SURROGATE_PAIR_LEN : 1;
|
|
26
|
+
state.draftText = state.draftText.slice(0, state.cursorIndex - deleteCount) + state.draftText.slice(state.cursorIndex);
|
|
27
|
+
state.cursorIndex -= deleteCount;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 光标左移一个 code point(surrogate pair 安全)。不超出 0。 */
|
|
32
|
+
export function moveCursorLeft(state: QuestionState): void {
|
|
33
|
+
const newLeft = state.cursorIndex - 1;
|
|
34
|
+
state.cursorIndex = newLeft > 0 && isHighSurrogate(state.draftText, newLeft - 1)
|
|
35
|
+
? newLeft - 1
|
|
36
|
+
: Math.max(0, newLeft);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 光标右移一个 code point(surrogate pair 安全)。不超出 draftText.length。 */
|
|
40
|
+
export function moveCursorRight(state: QuestionState): void {
|
|
41
|
+
state.cursorIndex = isHighSurrogate(state.draftText, state.cursorIndex)
|
|
42
|
+
? Math.min(state.draftText.length, state.cursorIndex + SURROGATE_PAIR_LEN)
|
|
43
|
+
: Math.min(state.draftText.length, state.cursorIndex + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 光标移到行首。 */
|
|
47
|
+
export function moveCursorHome(state: QuestionState): void {
|
|
48
|
+
state.cursorIndex = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 光标移到行尾。 */
|
|
52
|
+
export function moveCursorEnd(state: QuestionState): void {
|
|
53
|
+
state.cursorIndex = state.draftText.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 多字符粘贴 chunk → printable 提取。
|
|
57
|
+
* 排除 bracketed paste 标记,过滤未识别控制序列(OSC/DA/DCS/APC/unknown CSI)。
|
|
58
|
+
* 返回 true 表示有文本插入。
|
|
59
|
+
* data 整体性由 StdinBuffer 序列拆分保证。 */
|
|
60
|
+
export function handleEditorPaste(state: QuestionState, data: string): boolean {
|
|
61
|
+
if (data.startsWith("\x1b") && !data.includes("\x1b[200~") && !data.includes("\x1b[201~")) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
|
65
|
+
let changed = false;
|
|
66
|
+
// 用 Array.from 正确拆分 emoji/surrogate pairs
|
|
67
|
+
for (const c of Array.from(cleaned)) {
|
|
68
|
+
if (c >= " ") {
|
|
69
|
+
state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
|
|
70
|
+
state.cursorIndex += c.length;
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return changed;
|
|
75
|
+
}
|