@qnroa/qtype 0.2.2 → 0.2.3

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,53 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.2.3] — 2026-09-07
17
+
18
+ Fixes long-standing bugs around Chinese IME input on the typing arena
19
+ and cleans up how "ignore punctuation" behaves. The invisible editor
20
+ is now a `<input>` instead of a contenteditable div, matching the
21
+ pattern every mainstream Chinese typing site uses; character capture
22
+ reads `el.value` on the `input` event, so IME commits arrive as the
23
+ final glyph — no more races between keydown and beforeinput.
24
+
25
+ ### Added
26
+ - **Miss-slot display setting** (Appearance → Miss display). Default
27
+ keeps the target glyph visible in red (matches Monkeytype / keybr
28
+ / ohMyType / ptype conventions — user sees *what should have been
29
+ there*). Flip to "Typed" to render the user's actual keystroke in
30
+ red at the miss slot instead — useful when reviewing exactly what
31
+ went wrong.
32
+
33
+ ### Changed
34
+ - **Editor element is now a hidden `<input>`, not `<div
35
+ contentEditable>`.** IME commits and macOS smart-punctuation
36
+ substitutions land in `el.value` as the final glyph, which the
37
+ `input` event reads and emits to the engine. Composition (pinyin
38
+ preedit) is gated on the event's own `isComposing` /
39
+ `inputType === 'insertCompositionText'` signals rather than the
40
+ hook's state machine, so browsers that fire `input` before
41
+ `compositionstart` can't leak preedit letters through as
42
+ characters.
43
+ - **`Ignore punctuation` now rewrites punctuation to space at engine
44
+ construction.** Previously the engine fast-forwarded through
45
+ punctuation slots inside `onKeyDown`, which left the arena still
46
+ showing punctuation and the on-screen keyboard still hinting the
47
+ punctuation key. Now every punctuation codepoint in the target
48
+ becomes a regular space up-front — the arena, the keyboard hint
49
+ and the engine all see the same target sequence, no divergence.
50
+ Toggling the setting mid-card rebuilds the engine via the
51
+ `useTypingEngine` memo.
52
+
53
+ ### Fixed
54
+ - **Chinese full-width punctuation shown as ASCII.** The old
55
+ keydown-based capture path picked up whichever of `keydown.key` /
56
+ `beforeinput.data` fired first and could substitute a half-width
57
+ glyph. Moving to `<input>` + `input` event and always rendering
58
+ the target glyph (default) puts an end to this class of bug.
59
+ - **Dead `softInsertText` handler removed** from `INPUT_TYPE_HANDLERS`
60
+ and the `handleBeforeInput` fast-path; character emission has a
61
+ single owner now (the `input` event).
62
+
16
63
  ## [0.2.2] — 2026-09-01
17
64
 
18
65
  A responsive footer, a convention-based way to swap the site logo
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,42 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.2.3] — 2026-09-07
16
+
17
+ 修复打字界面在中文输入法下的一系列老问题,并把"忽略标点"的行为
18
+ 理顺。隐藏的输入编辑器从 contenteditable 换成 `<input>`,和主流
19
+ 中文打字站的做法一致;字符捕获改为在 `input` 事件里读 `el.value`,
20
+ IME 提交后的最终字符直接进引擎 —— 不再有 keydown 和 beforeinput
21
+ 的竞态。
22
+
23
+ ### 新增
24
+ - **错误显示设置**(外观 → 错误显示)。默认保持目标字符可见(红色),
25
+ 和 Monkeytype / keybr / ohMyType / ptype 一致 —— 用户看到的是
26
+ "本来应该是什么"。切到"实际输入"则在错误位置显示用户实际敲入
27
+ 的字符(仍然红色),方便复盘到底敲错在哪。
28
+
29
+ ### 变更
30
+ - **编辑器元素从 `<div contentEditable>` 改为隐藏 `<input>`。** IME
31
+ 提交和 macOS 智能标点替换都会把最终字符写进 `el.value`,由
32
+ `input` 事件读到再喂给引擎。组词状态(拼音预输入)靠事件自身的
33
+ `isComposing` / `inputType === 'insertCompositionText'` 判断,
34
+ 不再依赖 hook 状态机的时序;这样即便浏览器先 `input` 后
35
+ `compositionstart`,预输入的拼音字母也不会以字符形式泄漏出去。
36
+ - **`忽略标点` 改为在引擎构造时把标点重写成空格。** 之前是引擎在
37
+ `onKeyDown` 里 fast-forward 跳过标点位置,但界面上标点还在显示,
38
+ 键盘还在提示打标点键。现在所有标点 codepoint 上来就变空格 ——
39
+ arena、键盘提示、引擎看到的目标序列完全一致,不会跑偏。中途切换
40
+ 设置时通过 `useTypingEngine` 的 memo 重建引擎。
41
+
42
+ ### 修复
43
+ - **中文全角标点显示成 ASCII。** 老的 keydown 捕获路径会挑
44
+ `keydown.key` 或 `beforeinput.data` 里先来的那个,可能替换成半角。
45
+ 改用 `<input>` + `input` 事件、并默认在打错位置显示目标字符,
46
+ 这类问题一次性解决。
47
+ - **删除死代码 `softInsertText`。** `INPUT_TYPE_HANDLERS` 和
48
+ `handleBeforeInput` 里的 `insertText` 特殊分支一起删掉,字符发射
49
+ 只由 `input` 事件负责。
50
+
15
51
  ## [0.2.2] — 2026-09-01
16
52
 
17
53
  响应式页脚,约定式自定义 logo 和 favicon(不用改代码),以及把 view
@@ -31,10 +31,28 @@ export class TypingInput {
31
31
  #position;
32
32
  #listeners = new Set();
33
33
  constructor(text, settings) {
34
- this.#target = text;
35
- this.#targetCodepoints = Array.from(text, (ch) => ch.codePointAt(0) ?? 0);
36
- this.#length = this.#targetCodepoints.length;
37
34
  this.settings = { ...defaultTypingSettings, ...settings };
35
+ // ignorePunctuation replaces every punctuation codepoint with a
36
+ // regular space in the target sequence. The engine, the arena
37
+ // renderer, and the keyboard hint all just see a space — no
38
+ // fast-forward machinery, no special-case display, no divergence
39
+ // between what the user sees and what they need to type.
40
+ const raw = Array.from(text, (ch) => ch.codePointAt(0) ?? 0);
41
+ this.#targetCodepoints = this.settings.ignorePunctuation
42
+ ? raw.map((cp) => (isPunctuation(cp) ? 0x20 : cp))
43
+ : raw;
44
+ // The effective target string: rewritten if ignorePunctuation is
45
+ // on, else identical to `text`. Anything that consumes
46
+ // `engine.target` (keyboard-hint plans, mini-arena mirrors, etc.)
47
+ // sees exactly the sequence the engine expects the user to type.
48
+ // We avoid `String.fromCodePoint(...arr)` here — spreading a large
49
+ // codepoint array can blow the JS engine's argument-count ceiling
50
+ // on unusually long targets. Building char-by-char is O(n) with no
51
+ // fixed upper bound.
52
+ this.#target = this.settings.ignorePunctuation
53
+ ? this.#targetCodepoints.map((cp) => String.fromCodePoint(cp)).join('')
54
+ : text;
55
+ this.#length = this.#targetCodepoints.length;
38
56
  this.#chars = this.#buildInitialChars();
39
57
  this.#position = 0;
40
58
  }
@@ -60,20 +78,7 @@ export class TypingInput {
60
78
  if (this.#position >= this.#length) {
61
79
  return Feedback.Correct;
62
80
  }
63
- let expected = this.#targetCodepoints[this.#position];
64
- // ignorePunctuation: fast-forward through any punctuation slots
65
- // before matching the user's keystroke against the next
66
- // typable character. Uses a broad Unicode range so it works
67
- // for both ASCII and CJK punctuation.
68
- if (this.settings.ignorePunctuation) {
69
- while (this.#position < this.#length &&
70
- isPunctuation(this.#targetCodepoints[this.#position])) {
71
- this.#autoAdvance(timestamp);
72
- }
73
- if (this.#position >= this.#length)
74
- return Feedback.Correct;
75
- expected = this.#targetCodepoints[this.#position];
76
- }
81
+ const expected = this.#targetCodepoints[this.#position];
77
82
  // Normalize both sides through the same equivalence table so
78
83
  // fullwidth/half-width / smart-quote / CJK-punctuation variants
79
84
  // don't cause false misses. Display remains untouched.
@@ -195,28 +200,6 @@ export class TypingInput {
195
200
  for (const l of this.#listeners)
196
201
  l();
197
202
  }
198
- /**
199
- * Mark the current slot as auto-advanced Hit (used by
200
- * `ignorePunctuation`). Records the timestamp so stats include the
201
- * auto-advance in `startedAt` timing, but doesn't count it toward
202
- * user keystroke totals (still shows as Hit — the punctuation was
203
- * "correctly typed" via the auto-advance rule).
204
- */
205
- #autoAdvance(timestamp) {
206
- if (this.#position >= this.#length)
207
- return;
208
- const expected = this.#targetCodepoints[this.#position];
209
- const next = this.#chars.slice();
210
- const prev = next[this.#position];
211
- next[this.#position] = {
212
- target: expected,
213
- typed: expected,
214
- attrs: (prev.attrs & ~(CharAttr.Miss | CharAttr.Cursor)) | CharAttr.Hit,
215
- timestamp,
216
- };
217
- this.#chars = next;
218
- this.#position++;
219
- }
220
203
  }
221
204
  /**
222
205
  * ASCII case-insensitive comparison. Non-ASCII (CJK, accented Latin,
@@ -1,3 +1,3 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-Dd26zdqJ.js","assets/index-Dzj2d3KO.js","assets/index-C59VhMlk.css"])))=>i.map(i=>d[i]);
2
- import{r as l,_ as h,u as g,j as f,C as p,M as m,T as y,a as w}from"./index-Dzj2d3KO.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-Dd26zdqJ.js"),__vite__mapDeps([0,1,2])).then(async e=>(o=await e.createHighlighter({themes:["github-dark","github-light"],langs:[]}),o)),c)}async function _(e,t){if(!e.getLoadedLanguages().includes(t))try{await e.loadLanguage(t)}catch{}}function x(e){const[t,n]=l.useState(o);return l.useEffect(()=>{if(!e)return;let r=!1;return(async()=>{const s=await L();await _(s,e),r||n(s)})(),()=>{r=!0}},[e]),t}const A=/```(\w+)/;function E(e){const t=e.toLowerCase();return t.includes("typescript")||t.endsWith(".ts")?"typescript":t.includes("python")||t.endsWith(".py")?"python":t.includes("cpp")||t.includes("c++")?"cpp":t.includes("java")&&!t.includes("script")?"java":t.includes("go")?"go":t.includes("rust")||t.endsWith(".rs")?"rust":t.includes("code-c")||t.endsWith(".c")?"c":"javascript"}function j(e,t){if(e){const n=e.match(A);if(n)return n[1]}return E(t)}function k(e,t,n,r){if(!r||!r.getLoadedLanguages().includes(t))return new Array(Array.from(e).length).fill(null);try{const s=r.codeToTokens(e,{lang:t,theme:n}),i=[];for(const u of s.tokens){for(const a of u)for(const d of a.content)i.push(a.color??null);i.push(null)}return!e.endsWith(`
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-Cj70GP_V.js","assets/index-CeFBpbIw.js","assets/index-VPadwWzO.css"])))=>i.map(i=>d[i]);
2
+ import{r as l,_ as h,u as g,j as f,C as p,M as m,T as y,a as w}from"./index-CeFBpbIw.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-Cj70GP_V.js"),__vite__mapDeps([0,1,2])).then(async e=>(o=await e.createHighlighter({themes:["github-dark","github-light"],langs:[]}),o)),c)}async function _(e,t){if(!e.getLoadedLanguages().includes(t))try{await e.loadLanguage(t)}catch{}}function x(e){const[t,n]=l.useState(o);return l.useEffect(()=>{if(!e)return;let r=!1;return(async()=>{const s=await L();await _(s,e),r||n(s)})(),()=>{r=!0}},[e]),t}const A=/```(\w+)/;function E(e){const t=e.toLowerCase();return t.includes("typescript")||t.endsWith(".ts")?"typescript":t.includes("python")||t.endsWith(".py")?"python":t.includes("cpp")||t.includes("c++")?"cpp":t.includes("java")&&!t.includes("script")?"java":t.includes("go")?"go":t.includes("rust")||t.endsWith(".rs")?"rust":t.includes("code-c")||t.endsWith(".c")?"c":"javascript"}function j(e,t){if(e){const n=e.match(A);if(n)return n[1]}return E(t)}function k(e,t,n,r){if(!r||!r.getLoadedLanguages().includes(t))return new Array(Array.from(e).length).fill(null);try{const s=r.codeToTokens(e,{lang:t,theme:n}),i=[];for(const u of s.tokens){for(const a of u)for(const d of a.content)i.push(a.color??null);i.push(null)}return!e.endsWith(`
3
3
  `)&&i.length>Array.from(e).length&&i.pop(),i}catch{return new Array(Array.from(e).length).fill(null)}}function v(e){var d;const{material:t,card:n}=e,[r]=g(),s=l.useMemo(()=>r?j(n.question,t.filePath):"",[n.question,t.filePath,r]),i=typeof document<"u"&&((d=document.documentElement.getAttribute("data-theme"))!=null&&d.includes("latte"))?"github-light":"github-dark",u=x(r?s:""),a=l.useMemo(()=>r?k(n.answer,s,i,u):void 0,[n.answer,s,i,u,r]);return f.jsx(p,{type:"code",variant:"flow",slots:[{role:"title",content:n.title},n.question?{role:"question",content:f.jsx(m,{source:n.question})}:{role:"question",content:null},{role:"arena",content:f.jsx(y,{...w(e),charColors:a},n.id)}]})}export{v as CodeCard};