@qnroa/qtype 0.2.3 → 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,90 @@ 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
+
60
+ ## [0.2.4] — 2026-09-08
61
+
62
+ Bug-fix + refactor pass around the ignore-punctuation setting and the
63
+ plumbing that surfaces settings in the command palette and HUD menu.
64
+
65
+ ### Fixed
66
+ - **Ignore-punctuation used to eat operators.** `+`, `-`, `*`, `/`,
67
+ `=`, `<`, `>`, `(`, `)` and other code / math symbols were treated
68
+ as punctuation, so turning the setting on rewrote whole code
69
+ passages into runs of blanks. The punctuation set is now the same
70
+ 9 ASCII marks keybr / ptype / Monkeytype recognise
71
+ (`. , ! ? ; : ' " -` — sentence and clause marks only) plus the
72
+ corresponding CJK sentence marks, smart quotes, and Chinese book /
73
+ corner-bracket pairs. Operators and brackets pass through
74
+ unchanged.
75
+ - **Arena stale-render after toggling ignore-punctuation.** Switching
76
+ the setting mid-card left the arena rendered against the previous
77
+ engine's target for a tick, so punctuation slots appeared to
78
+ linger until the next event. The arena is now keyed on
79
+ `${card.id}::${engine.target}`, forcing a full remount whenever
80
+ the engine's effective target changes — no stale
81
+ `useSyncExternalStore` subscription, no leftover DOM.
82
+
83
+ ### Changed
84
+ - **HUD ⋮ menu is now registry-driven.** Every settings entry that
85
+ opts in via `hud: { icon, order? }` shows up as a quick toggle
86
+ during typing. `hint`, `ignoreCase`, `ignorePunctuation`,
87
+ `allowBackspace`, `showKeyboard`, `showHands` land in the menu by
88
+ default; `showPhonetic`, `showGloss`, `autoSpeak` show up on word
89
+ / sentence cards. Adding a new HUD toggle is now one registry
90
+ line.
91
+ - **Command palette lists every setting.** The palette walks the
92
+ settings registry and emits a `setting.<id>` command per entry —
93
+ bool settings toggle inline; enum / number / action settings open
94
+ the settings dialog scrolled to the row. No more manually adding
95
+ palette entries for each new setting.
96
+ - **`useHint()` extracted.** Three call sites were inlining the
97
+ same `usePersistedState<HintMode>('settings', 'hint', ...)` block;
98
+ they all use the shared hook now.
99
+
16
100
  ## [0.2.3] — 2026-09-07
17
101
 
18
102
  Fixes long-standing bugs around Chinese IME input on the typing arena
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,72 @@ 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
+
49
+ ## [0.2.4] — 2026-09-08
50
+
51
+ 围绕"忽略标点"设置和"命令面板 / HUD 菜单如何呈现设置"两件事,
52
+ 做了一轮修复和小重构。
53
+
54
+ ### 修复
55
+ - **忽略标点误伤运算符。** 之前 `+ - * / = < > ( )` 等代码 / 数学
56
+ 符号都被当成标点,一开设置就把整段代码替换成空格。改成和
57
+ keybr / ptype / Monkeytype 一致的 9 个 ASCII 句读
58
+ (`. , ! ? ; : ' " -`),加上对应的中文句号 / 逗号 / 冒号 / 分号 /
59
+ 问号 / 感叹号 / 顿号 + 中文引号 / 书名号 / 角括号。运算符和括号
60
+ 一律不动。
61
+ - **切换忽略标点后 arena 一瞬间残留旧渲染。** 卡片打字中途切设置,
62
+ 引擎重建了但 arena 的 fiber 被复用,`useSyncExternalStore`
63
+ 订阅还挂在旧 engine 上,标点位置视觉错乱,得刷新才好。arena
64
+ 的 React key 改成 `${card.id}::${engine.target}`,engine 目标
65
+ 一变就整棵重挂,零残留。
66
+
67
+ ### 变更
68
+ - **HUD ⋮ 菜单改为注册表驱动。** 设置条目里加 `hud: { icon, order? }`
69
+ 就自动出现在打字时的快捷菜单。默认加入的:显示提示、忽略大小写、
70
+ 忽略标点、允许退格、显示键盘、显示手位;word / sentence 卡片
71
+ 额外多显示音标 / 释义 / 自动朗读。加一个 HUD 快捷开关现在只需
72
+ 改注册表一行。
73
+ - **命令面板列出所有设置。** 面板遍历设置注册表,给每个条目生成一条
74
+ `setting.<id>` 命令。bool 类型直接就地翻转;枚举 / 数字 / action
75
+ 打开设置对话框并高亮那一行。新增设置不再需要单独往命令面板加
76
+ 条目。
77
+ - **抽出 `useHint()` hook。** 之前 3 个地方内联同样的
78
+ `usePersistedState<HintMode>('settings', 'hint', ...)`,现在统一用
79
+ 这个 hook。
80
+
15
81
  ## [0.2.3] — 2026-09-07
16
82
 
17
83
  修复打字界面在中文输入法下的一系列老问题,并把"忽略标点"的行为
@@ -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
@@ -216,35 +268,100 @@ function sameLetter(a, b) {
216
268
  return false;
217
269
  }
218
270
  /**
219
- * Rough Unicode punctuation test ASCII punctuation + CJK
220
- * fullwidth range + common typographic marks. Kept explicit
221
- * (no `\p{P}` regex) so the check is fast and predictable.
271
+ * Sentence / clause punctuation, per keybr + ptype + Monkeytype
272
+ * conventions. Explicitly excludes math operators (`+ - * / = < >`),
273
+ * brackets (`( ) [ ] { }`), and code-symbol characters (`_ | \ @ # $
274
+ * % & ^ ~ \``) — those show up in real prose and code and should be
275
+ * typed as-is, not silently rewritten to space.
276
+ *
277
+ * Uses an inline set of codepoints instead of `\p{P}` — the Unicode
278
+ * punctuation category also matches math / brackets / dashes we want
279
+ * to leave alone, which is exactly why every mainstream typing app
280
+ * hard-codes its own list.
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.
222
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
+ }
223
326
  function isPunctuation(cp) {
224
- // ASCII punctuation: ! " # $ % & ' ( ) * + , - . / : ; < = > ? @
225
- // [ \ ] ^ _ ` { | } ~
226
- if (cp >= 0x21 && cp <= 0x2f)
227
- return true;
228
- if (cp >= 0x3a && cp <= 0x40)
229
- return true;
230
- if (cp >= 0x5b && cp <= 0x60)
231
- return true;
232
- if (cp >= 0x7b && cp <= 0x7e)
233
- return true;
234
- // CJK Symbols and Punctuation
235
- if (cp >= 0x3000 && cp <= 0x303f)
236
- return true;
237
- // Fullwidth ASCII forms (includes fullwidth punctuation)
238
- if (cp >= 0xff01 && cp <= 0xff0f)
239
- return true;
240
- if (cp >= 0xff1a && cp <= 0xff20)
241
- return true;
242
- if (cp >= 0xff3b && cp <= 0xff40)
243
- return true;
244
- if (cp >= 0xff5b && cp <= 0xff65)
245
- return true;
246
- // General Punctuation block
247
- if (cp >= 0x2000 && cp <= 0x206f)
248
- return true;
249
- return false;
327
+ switch (cp) {
328
+ // ASCII sentence / clause marks. . , ! ? ; : ' "
329
+ case 0x2e:
330
+ case 0x2c:
331
+ case 0x21:
332
+ case 0x3f:
333
+ case 0x3b:
334
+ case 0x3a:
335
+ case 0x27:
336
+ case 0x22:
337
+ // CJK sentence marks: 。 、
338
+ case 0x3001:
339
+ case 0x3002:
340
+ // Fullwidth forms: ! ? , : ; ' "
341
+ case 0xff01:
342
+ case 0xff1f:
343
+ case 0xff0c:
344
+ case 0xff1a:
345
+ case 0xff1b:
346
+ case 0xff07:
347
+ case 0xff02:
348
+ // Smart quotes: " " ' '
349
+ case 0x201c:
350
+ case 0x201d:
351
+ case 0x2018:
352
+ case 0x2019:
353
+ // Book title marks: 《 》 〈 〉
354
+ case 0x300a:
355
+ case 0x300b:
356
+ case 0x3008:
357
+ case 0x3009:
358
+ // CJK corner brackets: 「 」 『 』
359
+ case 0x300c:
360
+ case 0x300d:
361
+ case 0x300e:
362
+ case 0x300f:
363
+ return true;
364
+ default:
365
+ return false;
366
+ }
250
367
  }
@@ -3,4 +3,5 @@ export const defaultTypingSettings = {
3
3
  ignoreCase: false,
4
4
  ignorePunctuation: false,
5
5
  allowBackspace: true,
6
+ autoSkipIndent: false,
6
7
  };