@qnroa/qtype 0.2.4 → 0.2.5

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/CHANGELOG.md CHANGED
@@ -13,6 +13,50 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.2.5] — 2026-09-14
17
+
18
+ Second pass on card-lifecycle correctness plus a code-typing quality-
19
+ of-life feature. The completion pill no longer overlaps card content,
20
+ navigating between done and not-done cards no longer poisons the
21
+ fresh card with a "already done" paint, and code cards can skip
22
+ leading indentation the way typing.io does.
23
+
24
+ ### Added
25
+ - **Auto-skip indent** (Typing rules → Auto-skip indent). Every line's
26
+ leading spaces / tabs are pre-hit for the user — the cursor jumps to
27
+ the first real character on each line, so code cards no longer force
28
+ you to type ten spaces before every statement. On by default; toggle
29
+ in Settings or via the HUD ⋮ menu. Same behaviour typing.io ships as
30
+ its default and defended publicly against "just make me type it"
31
+ arguments in early user feedback. Blank lines (pure-whitespace lines)
32
+ are not skipped — the user still hits Enter for them.
33
+
34
+ ### Fixed
35
+ - **Completion pill overlapping card content.** The "press Enter to
36
+ continue" hint used to sit under the HUD as an absolute-positioned
37
+ pill; on code / article cards where the prompt sits right below the
38
+ HUD, it landed on top of the first line of content. The hint now
39
+ lives in a fixed-height notification strip between the stage and the
40
+ keyboard — same pattern Anki / Duolingo use — so it never overlays
41
+ content and layout doesn't shift when it appears or leaves.
42
+ - **Wrong card shown as "already done" after rewind then advance.**
43
+ Sequence: finish card 1 → auto-advance to card 2 → rewind to card 1
44
+ (correctly rendered as done) → advance back to card 2, which was
45
+ now painted green even though the user had never typed it. Root
46
+ cause: the route's `useEffect` for `markAllHit()` ran after `cardId`
47
+ changed but before the state machine's `CARD_ENTER` effect settled
48
+ the new `kind`, so a stale `completed` from the previous card
49
+ painted the fresh engine. The effect now cross-checks the
50
+ authoritative `cardDone` (from `useDoneState`) before calling
51
+ `markAllHit()`.
52
+ - **Auto-advance timer not scheduling after `manual → auto` mid-pass.**
53
+ If the user switched advance mode from manual to auto while sitting
54
+ in a `passed{awaitingAdvance}` state, the 700ms timer that fires
55
+ `STAMP_TIMEOUT` never scheduled — the effect that owns it had no
56
+ `advanceMode` dep and its `prev !== 'passed'` gate blocked re-entry.
57
+ The gate is now `stampTimerRef.current === null` (idempotent), and
58
+ `advanceMode` is a proper dep.
59
+
16
60
  ## [0.2.4] — 2026-09-08
17
61
 
18
62
  Bug-fix + refactor pass around the ignore-punctuation setting and the
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,40 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.2.5] — 2026-09-14
16
+
17
+ 围绕卡片生命周期的正确性又做了一轮修复,并加入一个代码打字的
18
+ 体验改进。完成提示条不再压卡片内容;在"已完成"和"未打"卡片
19
+ 之间来回切,新卡不再被误标为"已完成";代码卡按 typing.io 的做法
20
+ 自动跳过缩进。
21
+
22
+ ### 新增
23
+ - **自动跳过缩进**(打字规则 → 自动跳过缩进)。每行开头的空格 / Tab
24
+ 由系统自动填入,光标直接落在第一个真字符上 —— 代码卡不再逼你
25
+ 每句前先敲十个空格。默认开启,可在设置或 HUD ⋮ 菜单里切换。
26
+ typing.io 默认就是这个行为,作者曾公开对"就让用户自己打空格吧"
27
+ 这种意见做过反驳。纯空白行(只有空格 + 换行)不自动跳过,用户
28
+ 仍需手动敲回车。
29
+
30
+ ### 修复
31
+ - **完成提示条压卡片内容。** "按 Enter 继续"提示原来是挂在 HUD
32
+ 下方的绝对定位气泡,在代码 / 文章类卡片(内容紧贴 HUD 下方)
33
+ 时会盖住第一行内容。现在提示条改成打字区和键盘之间的**固定
34
+ 高度通知条**,和 Anki / Duolingo 一样 —— 不遮内容,布局也不会
35
+ 在提示出现 / 消失时跳动。
36
+ - **回退再前进后新卡被误显示为"已完成"。** 场景:打完卡片 1 →
37
+ 自动到卡片 2 → 退回卡片 1(正确显示为完成)→ 再前进到卡片 2,
38
+ 卡片 2 被涂满绿色,尽管从没打过。根因:route 里的
39
+ `markAllHit()` useEffect 在 `cardId` 变化后先跑,状态机
40
+ `CARD_ENTER` effect 后跑,`kind` 仍是旧的 `completed`,把新引擎
41
+ 也涂绿了。现在这个 effect 会**同时校验** `useDoneState` 里真实
42
+ 的 `cardDone`,不再单信任 stale `kind`。
43
+ - **`manual → auto` 中途切换后自动前进 timer 不触发。** 用户在
44
+ `passed{awaitingAdvance}` 里把前进模式从 manual 切到 auto,原本
45
+ 700ms 定时器不启动 —— effect 的 deps 里没有 `advanceMode`,而且
46
+ `prev !== 'passed'` 的守卫挡住了二次进入。改成幂等守卫
47
+ (`stampTimerRef.current === null`)并把 `advanceMode` 加入 deps。
48
+
15
49
  ## [0.2.4] — 2026-09-08
16
50
 
17
51
  围绕"忽略标点"设置和"命令面板 / HUD 菜单如何呈现设置"两件事,
@@ -26,6 +26,10 @@ export class TypingInput {
26
26
  #target;
27
27
  #targetCodepoints;
28
28
  #length;
29
+ /** Per-slot flag: this slot is a leading-indent whitespace that the
30
+ * engine will auto-hit under `autoSkipIndent`. Computed once at
31
+ * construction from the target's line structure. */
32
+ #skipMask;
29
33
  settings;
30
34
  #chars;
31
35
  #position;
@@ -53,8 +57,17 @@ export class TypingInput {
53
57
  ? this.#targetCodepoints.map((cp) => String.fromCodePoint(cp)).join('')
54
58
  : text;
55
59
  this.#length = this.#targetCodepoints.length;
60
+ this.#skipMask = this.settings.autoSkipIndent
61
+ ? buildIndentSkipMask(this.#targetCodepoints)
62
+ : new Array(this.#length).fill(false);
56
63
  this.#chars = this.#buildInitialChars();
57
64
  this.#position = 0;
65
+ // If the target starts with indent whitespace, auto-advance past
66
+ // it before the user's first keystroke so the initial cursor is
67
+ // on the first "real" character. Subsequent lines are handled in
68
+ // `onKeyDown` after a newline hit lands the cursor on the next
69
+ // line's leading whitespace.
70
+ this.#autoAdvanceSkipped(0);
58
71
  }
59
72
  #buildInitialChars() {
60
73
  const chars = new Array(this.#length);
@@ -103,6 +116,12 @@ export class TypingInput {
103
116
  this.#chars = next;
104
117
  if (isHit || !this.settings.stopOnError) {
105
118
  this.#position++;
119
+ // After advancing, if the new position is an auto-skip indent
120
+ // whitespace (typical case: user just hit newline, cursor sits
121
+ // on the next line's leading spaces / tabs), fast-forward
122
+ // through those slots so the user's next keystroke lands on
123
+ // real code.
124
+ this.#autoAdvanceSkipped(timestamp);
106
125
  }
107
126
  this.#notify();
108
127
  if (recovered)
@@ -122,7 +141,13 @@ export class TypingInput {
122
141
  // Signal that the user is trying to rewind past start.
123
142
  return Feedback.Restart;
124
143
  }
125
- this.#position--;
144
+ // Skip back over any auto-hit indent whitespace so the user
145
+ // doesn't "rewind" into invisible-to-them spaces. Land on the
146
+ // most recent user-touched slot.
147
+ let target = this.#position - 1;
148
+ while (target > 0 && this.#skipMask[target])
149
+ target--;
150
+ this.#position = target;
126
151
  const next = this.#chars.slice();
127
152
  const prev = next[this.#position];
128
153
  // Clear Hit/Miss (this slot is now pending again) but remember it
@@ -143,8 +168,35 @@ export class TypingInput {
143
168
  reset() {
144
169
  this.#chars = this.#buildInitialChars();
145
170
  this.#position = 0;
171
+ this.#autoAdvanceSkipped(0);
146
172
  this.#notify();
147
173
  }
174
+ /**
175
+ * Walk `#position` past every consecutive auto-skip indent slot,
176
+ * marking each as an auto-hit so the arena renders them green.
177
+ * `timestamp` is stamped onto the skipped chars for consistency
178
+ * with real hits; downstream stats ignore auto-hit timestamps for
179
+ * WPM computation since they weren't user keystrokes.
180
+ */
181
+ #autoAdvanceSkipped(timestamp) {
182
+ if (!this.settings.autoSkipIndent)
183
+ return;
184
+ while (this.#position < this.#length &&
185
+ this.#skipMask[this.#position]) {
186
+ const idx = this.#position;
187
+ const expected = this.#targetCodepoints[idx];
188
+ const next = this.#chars.slice();
189
+ const prev = next[idx];
190
+ next[idx] = {
191
+ target: expected,
192
+ typed: expected,
193
+ attrs: (prev.attrs & ~(CharAttr.Miss | CharAttr.Cursor)) | CharAttr.Hit,
194
+ timestamp,
195
+ };
196
+ this.#chars = next;
197
+ this.#position++;
198
+ }
199
+ }
148
200
  /**
149
201
  * Mark every character as Hit — used by the route's `completed`
150
202
  * state (a card the user finished earlier) so downstream renderers
@@ -227,6 +279,50 @@ function sameLetter(a, b) {
227
279
  * to leave alone, which is exactly why every mainstream typing app
228
280
  * hard-codes its own list.
229
281
  */
282
+ /**
283
+ * Mark every codepoint that is leading whitespace on its line — a run
284
+ * of ASCII space (0x20) or tab (0x09) at line-start, up to (but not
285
+ * including) the first non-whitespace character on that line. Blank
286
+ * lines (nothing but whitespace between two newlines) are not
287
+ * flagged — the user still hits Enter for them, and there is no
288
+ * "indent" to skip.
289
+ */
290
+ function buildIndentSkipMask(cps) {
291
+ const mask = new Array(cps.length).fill(false);
292
+ let atLineStart = true;
293
+ for (let i = 0; i < cps.length; i++) {
294
+ const cp = cps[i];
295
+ if (cp === 0x0a) {
296
+ atLineStart = true;
297
+ continue;
298
+ }
299
+ if (atLineStart && (cp === 0x20 || cp === 0x09)) {
300
+ mask[i] = true;
301
+ continue;
302
+ }
303
+ atLineStart = false;
304
+ }
305
+ // Blank lines: a run of whitespace immediately followed by a
306
+ // newline is not "indentation" — un-flag it so the user still has
307
+ // to hit Enter (or space, if the line is really pure spaces) to
308
+ // pass. Walk each flagged run and check whether it ends at a
309
+ // newline before any non-whitespace.
310
+ for (let i = 0; i < mask.length; i++) {
311
+ if (!mask[i])
312
+ continue;
313
+ let j = i;
314
+ while (j < mask.length && mask[j])
315
+ j++;
316
+ // j is the first non-mask index after the run. If cps[j] is a
317
+ // newline or end-of-input, this run isn't indent — clear it.
318
+ if (j >= cps.length || cps[j] === 0x0a) {
319
+ for (let k = i; k < j; k++)
320
+ mask[k] = false;
321
+ }
322
+ i = j - 1;
323
+ }
324
+ return mask;
325
+ }
230
326
  function isPunctuation(cp) {
231
327
  switch (cp) {
232
328
  // ASCII sentence / clause marks. . , ! ? ; : ' "
@@ -3,4 +3,5 @@ export const defaultTypingSettings = {
3
3
  ignoreCase: false,
4
4
  ignorePunctuation: false,
5
5
  allowBackspace: true,
6
+ autoSkipIndent: false,
6
7
  };