diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..18aff7744 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,241 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +keybr.com is a touch typing learning application built with React, TypeScript, and Node.js. It uses a monorepo structure with multiple packages managed by npm workspaces. The application consists of a Node.js server backend and a webpack-bundled browser frontend. + +## Commands + +### Setup and Installation +```bash +npm install # Install all dependencies +cp .env.example /etc/keybr/env # Create config file (recommended global location) +./packages/devenv/lib/initdb.ts # Initialize database and create example users +``` + +### Development +```bash +npm start # Start dev server at http://localhost:3000 +npm run watch # Auto-rebuild on file changes (run alongside npm start) +npm run compile # Compile TypeScript for all packages +npm run build-dev # Build webpack bundles in development mode +npm run build # Build webpack bundles in production mode +``` + +### Testing +```bash +env DATABASE_CLIENT=sqlite npm test # Run all tests +npm test --workspace=@keybr/lesson # Run tests for specific package +``` + +### Linting and Formatting +```bash +npm run lint # Lint JavaScript/TypeScript files +npm run lint-fix # Auto-fix linting issues +npm run stylelint # Lint CSS/LESS files +npm run stylelint-fix # Auto-fix style issues +npm run format # Format code with Prettier +``` + +### Package Management +```bash +npm run clean # Clean all build artifacts +lage compile --no-cache # Compile using lage build system +lage test --no-cache # Test using lage build system +``` + +### Language and Content Generation +```bash +npm --workspace packages/keybr-generators run generate-languages # Generate phonetic models +npm run translate # Translation utilities +``` + +## Architecture + +### Monorepo Structure + +The codebase is organized as an npm workspace monorepo with 60+ packages in `packages/`: + +**Core Domain Packages:** +- `keybr-lesson` - Lesson generation logic and learning algorithms +- `keybr-keyboard` - Keyboard layouts and language definitions +- `keybr-phonetic-model` - Statistical language models for word generation +- `keybr-result` - Typing test results and statistics +- `keybr-settings` - User settings and preferences +- `keybr-textinput` - Text input handling and validation + +**UI Packages:** +- `keybr-widget` - Shared UI components +- `keybr-lesson-ui` - Lesson-related UI components +- `keybr-keyboard-ui` - Keyboard visualization components +- `keybr-textinput-ui` - Text input UI components +- `keybr-chart` - Charting and data visualization + +**Page Packages:** +- `page-practice` - Main practice page +- `page-profile` - User profile page +- `page-account` - Account management +- `page-typing-test` - Typing test page +- `page-multiplayer` - Multiplayer mode +- `page-highscores` - Leaderboards +- `page-layouts` - Keyboard layout browser +- `page-help` - Help and documentation + +**Infrastructure:** +- `server` - Main Node.js server application +- `server-cli` - CLI tools +- `keybr-database` - Database models and migrations (Knex + Objection.js) +- `keybr-pages-browser` - Browser entry point and routing +- `keybr-pages-server` - Server-side rendering +- `keybr-oauth` - OAuth authentication +- `test-env-*` - Test environment setup packages + +### Application Entry Points + +**Server:** `packages/server/lib/main.ts` +- Uses Node.js cluster module for multi-process architecture +- Forks 4 HTTP worker processes (port 3000 by default) +- Forks 1 WebSocket worker process for multiplayer (port 3001) +- Each worker automatically restarts on failure + +**Browser:** `packages/keybr-pages-browser/lib/entry.ts` +- Main React application entry point +- Uses React Router for client-side routing +- Lazy-loads page components + +### Web Framework + +The server uses a custom framework called `@fastr/*`: +- `@fastr/core` - Core application framework +- `@fastr/invert` - Dependency injection container +- `@fastr/middleware-*` - Express-like middleware +- Middleware are composed in `packages/server/lib/app/module.ts` + +### Database + +- **ORM:** Knex query builder + Objection.js models +- **Supported databases:** SQLite (dev), MySQL (production), better-sqlite3 +- **Schema:** Defined in `packages/keybr-database/lib/model.ts` +- **Main tables:** User, UserExternalId, Order, UserLoginRequest +- **Data loaders:** Packages like `keybr-result-loader`, `keybr-settings-loader` handle data persistence + +### Build System + +**Webpack Configuration:** `webpack.config.js` +- Two separate builds: "server" (Node.js target) and "browser" (web target) +- Server build outputs to `root/lib/` +- Browser build outputs to `root/public/assets/` +- Uses content hashing for production builds +- Shared chunks: vendor libraries, widgets, keyboard components +- CSS extraction with MiniCssExtractPlugin +- LESS preprocessing with CSS Modules (camelCase, hashed class names) +- TypeScript compiled with ts-loader +- Compression (gzip + brotli) in production + +**Task Runner:** Uses `lage` for orchestrating package builds and tests across the monorepo. + +**TypeScript:** Each package has its own `tsconfig.json` extending template configs from `packages/tsconfig-template*.json` + +### Internationalization + +- Uses `react-intl` for i18n +- Custom transformer `@keybr/scripts/intl-transformer.js` extracts and compiles messages +- Translation workflow via POEditor (see `scripts/poeditor-pull.js`) +- Messages defined inline with `formatMessage` and enforced by ESLint rules + +### Code Generation and Grammars + +The `keybr-code` package includes: +- Programming language syntax parsers and grammars +- Code generation for practice lessons +- Grammar files in `packages/keybr-code/lib/syntax/` + +### Lesson Generation Algorithm + +The core typing lesson algorithm: +1. **Phonetic model** generates realistic words based on language statistics +2. **Key progression** system adds letters incrementally based on user performance +3. **Target speed** tracking adapts difficulty to user's skill level +4. **Statistics collection** tracks per-key speed and accuracy +5. **Learning rate** calculations predict progress + +Key packages: `keybr-lesson`, `keybr-phonetic-model`, `keybr-learningrate` + +### State Management + +- React Context API for global state (no Redux) +- Settings stored in database and loaded via data loaders +- Real-time updates via WebSocket for multiplayer mode + +## Package Dependencies + +Packages use `*` for internal dependencies in package.json, resolved via workspace protocol. External dependencies are pinned versions. When adding a new package dependency: +- Use `@keybr/*` namespace for internal packages +- Add to `dependencies` in package.json +- Import with `.js` extension (required by ESLint) + +## Testing + +- Test runner: Custom `tstest` command (defined in package scripts) +- Test environment packages provide setup: `test-env-browser`, `test-env-bundler`, `test-env-server` +- Testing libraries: `@testing-library/react`, `@testing-library/dom`, `@testing-library/user-event` +- Tests located alongside source: `lib/**/*.test.{ts,tsx}` +- SQLite required for tests: `env DATABASE_CLIENT=sqlite npm test` + +## Code Style + +**ESLint config:** `eslint.config.js` (Flat config format) +- Enforces type imports with `@typescript-eslint/consistent-type-imports` +- Requires `.js` file extensions in imports (`n/file-extension-in-import`) +- Import sorting with `eslint-plugin-simple-import-sort` +- React hooks rules enforced in browser code +- formatjs rules for i18n message validation + +**TypeScript conventions:** +- Prefer `type` over `interface` (enforced) +- Use inline type imports: `import { type Foo } from "./foo.js"` +- Decorators enabled for dependency injection + +**React conventions:** +- JSX runtime (no React import needed) +- Fragments use `<>` syntax +- Boolean props always explicit: `` +- Handler names follow convention (enforced by `react/jsx-handler-names`) + +## Adding New Features + +### Adding a Language +See `docs/custom_language.md`: +1. Define language in `packages/keybr-keyboard/lib/language.ts` +2. Add dictionary file: `packages/keybr-generators/dictionaries/dictionary-.csv.gz` +3. Run `npm --workspace packages/keybr-generators run generate-languages` +4. Commit generated model and word list files + +### Adding a Keyboard Layout +See `docs/custom_keyboard.md` for the process. + +### Adding a Page +1. Create package in `packages/page-/` +2. Export main component from `lib/index.ts` +3. Add route in `packages/keybr-pages-browser/lib/pages/` +4. Import and register in routing configuration + +## Configuration + +**Environment variables:** Configured via `.env` or `/etc/keybr/env` +- `APP_URL` - Application URL +- `DATABASE_CLIENT` - Database type (sqlite/mysql) +- `DATABASE_FILENAME` - SQLite database path +- `DATA_DIR` - Data directory location +- `COOKIE_DOMAIN`, `COOKIE_SECURE` - Cookie settings +- `MAIL_*` - Email configuration + +## Important Notes + +- **Node version:** Requires Node.js v24+ +- **File extensions:** All imports must include `.js` extension (enforced) +- **Module type:** Package is ESM (`"type": "module"`) +- **Port 80/443:** Requires capability: `sudo setcap cap_net_bind_service=+ep $(which node)` +- **Character encoding:** Extensive Unicode support via `@keybr/unicode` package diff --git a/package-lock.json b/package-lock.json index 03e2d8ff6..071376979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -176,6 +176,7 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -285,6 +286,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -326,6 +328,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1929,6 +1932,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2128,6 +2132,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2138,6 +2143,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2214,6 +2220,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2684,6 +2691,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2741,6 +2749,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3199,6 +3208,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4686,6 +4696,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7201,6 +7212,7 @@ "integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -7913,6 +7925,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8577,6 +8590,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9107,6 +9121,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -9385,6 +9400,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9394,6 +9410,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10425,6 +10442,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -10973,6 +10991,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11253,6 +11272,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11435,6 +11455,7 @@ "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/packages/keybr-keyboard-ui/lib/Key.tsx b/packages/keybr-keyboard-ui/lib/Key.tsx index 56858b795..d44f8008c 100644 --- a/packages/keybr-keyboard-ui/lib/Key.tsx +++ b/packages/keybr-keyboard-ui/lib/Key.tsx @@ -1,5 +1,6 @@ import { type DeadCharacter, + type Keyboard, KeyCharacters, type KeyShape, type LabelShape, @@ -22,9 +23,14 @@ export type KeyProps = { export function makeKeyComponent( { letterName }: Language, shape: KeyShape, + keyboard?: Keyboard, ): FunctionComponent { const { isCodePoint, isDead, isLigature } = KeyCharacters; const { id, a, b, c, d } = shape; + + // Get extended characters for chord layouts (e.g., 月配列2-263) + const keyChars = keyboard?.characters.get(id); + const extendedChars = keyChars?.characters; const x = shape.x * keySize; const y = shape.y * keySize; const w = shape.w * keySize - keyGap; @@ -93,6 +99,28 @@ export function makeKeyComponent( if (isLigature(d)) { children.push(makeLigatureLabel(d, 25, 12, styles.secondarySymbol)); } + + // Display extended characters for chord layouts (layers 4, 5, 6) + if (extendedChars && extendedChars.length > 4) { + // Layer 4: ★ (KeyD chord) - display on right side, top + const e = extendedChars[4]; + if (isCodePoint(e)) { + children.push(makeCodePointLabel(e, 33, 10, styles.secondarySymbol)); + } + + // Layer 5: ☆ (KeyK chord) - display on right side, middle + const f = extendedChars[5]; + if (isCodePoint(f)) { + children.push(makeCodePointLabel(f, 33, 20, styles.secondarySymbol)); + } + + // Layer 6: ※ (KeyF chord) - display on right side, bottom + const g = extendedChars[6]; + if (isCodePoint(g)) { + children.push(makeCodePointLabel(g, 33, 30, styles.secondarySymbol)); + } + } + const zoneClassName = zoneClassNameOf(shape); function KeyComponent({ depressed, diff --git a/packages/keybr-keyboard-ui/lib/KeyLayer.tsx b/packages/keybr-keyboard-ui/lib/KeyLayer.tsx index 68bf41063..882489775 100644 --- a/packages/keybr-keyboard-ui/lib/KeyLayer.tsx +++ b/packages/keybr-keyboard-ui/lib/KeyLayer.tsx @@ -92,7 +92,7 @@ class MemoizedKeyElements { readonly keyboard: Keyboard, readonly shape: KeyShape, ) { - const Component = makeKeyComponent(keyboard.layout.language, shape); + const Component = makeKeyComponent(keyboard.layout.language, shape, keyboard); this.component = Component; this.state0 = ( 0 (base layer) + * + * // KeyK (☆) pressed + * resolveChordLayer(['KeyK', 'KeyQ'], metadata) // => 5 (☆ layer) + * + * // Multiple chord keys pressed - prioritize KeyD > KeyK > KeyF + * resolveChordLayer(['KeyD', 'KeyK'], metadata) // => 4 (★ layer has priority) + * ``` + */ +export function resolveChordLayer( + depressedKeys: readonly KeyId[], + chordMetadata: ChordLayoutMetadata, +): number { + const { chordModifiers } = chordMetadata; + + // Priority order: KeyD (★) > KeyK (☆) > KeyF (※) + // Check in priority order and return the first match + const priorityOrder: KeyId[] = ["KeyD", "KeyK", "KeyF"]; + + for (const priorityKey of priorityOrder) { + if ( + chordModifiers[priorityKey] !== undefined && + depressedKeys.includes(priorityKey) + ) { + return chordModifiers[priorityKey]; + } + } + + // No chord modifier keys pressed - return base layer + return 0; +} diff --git a/packages/keybr-keyboard/lib/keyboard.ts b/packages/keybr-keyboard/lib/keyboard.ts index b8fc6b403..91b669e53 100644 --- a/packages/keybr-keyboard/lib/keyboard.ts +++ b/packages/keybr-keyboard/lib/keyboard.ts @@ -6,6 +6,7 @@ import { KeyModifier } from "./keymodifier.ts"; import { KeyShape } from "./keyshape.ts"; import { getExampleLetters, getExampleText } from "./language.ts"; import { type Layout } from "./layout.ts"; +import { TSUKI_2_263_LEARNING_ORDER } from "./layout/ja_tsuki_2_263.ts"; import { type CharacterDict, type DeadCharacter, @@ -33,14 +34,32 @@ export class Keyboard { const shapes = new Map(); const zones = new Map(); - for (const [id, [a = null, b = null, c = null, d = null]] of Object.entries( - characterDict, - )) { - characters.set(id, new KeyCharacters(id, a, b, c, d)); + for (const [id, chars] of Object.entries(characterDict)) { + const [a = null, b = null, c = null, d = null] = chars; + // For chord layouts (length > 4), pass the full character array + const keyChars = + chars.length > 4 + ? new KeyCharacters(id, a, b, c, d, chars) + : new KeyCharacters(id, a, b, c, d); + characters.set(id, keyChars); } - for (const { id, a, b, c, d } of characters.values()) { - if (KeyCharacters.isCodePoint(a)) { + // Get chord modifier keys (D, K, F) that should not be registered as standalone keys + const chordModifierKeys = new Set(); + if (layout.chordMetadata?.chordModifiers) { + for (const keyId of Object.keys(layout.chordMetadata.chordModifiers)) { + chordModifierKeys.add(keyId); + } + } + + for (const keyChars of characters.values()) { + const { id, a, b, c, d } = keyChars; + + // Skip base character (index 0) for chord modifier keys + // They should only work in combination with other keys + const isChordModifier = chordModifierKeys.has(id); + + if (KeyCharacters.isCodePoint(a) && !isChordModifier) { addCombo(combos, a, id, KeyModifier.None); } if (KeyCharacters.isCodePoint(b)) { @@ -52,6 +71,17 @@ export class Keyboard { if (KeyCharacters.isCodePoint(d)) { addCombo(combos, d, id, KeyModifier.ShiftAlt); } + + // For chord layouts, add combos for extended character array + // This includes post-modifier results (dakuten, handakuten) and chord layers + if (keyChars.characters && keyChars.characters.length > 4) { + for (let i = 1; i < keyChars.characters.length; i++) { + const char = keyChars.characters[i]; + if (KeyCharacters.isCodePoint(char)) { + addCombo(combos, char, id, KeyModifier.None); + } + } + } } for (const { id, a, b, c, d } of characters.values()) { @@ -111,6 +141,10 @@ export class Keyboard { }: Partial = {}): WeightedCodePointSet { const list: CodePoint[] = []; const weights = new Map(); + + // Check if this is 月配列2-263 layout - use custom learning order + const useTsukiLearningOrder = this.layout.id === "ja-tsuki-2-263"; + for (const combo of this.combos.values()) { const shape = this.getShape(combo.id); if ( @@ -120,16 +154,54 @@ export class Keyboard { (zones == null || shape?.inAnyZone(zones)) ) { list.push(combo.codePoint); - switch (shape?.row) { - case "home": - weights.set(combo.codePoint, 1); - break; - case "top": - weights.set(combo.codePoint, 2); - break; + + if (useTsukiLearningOrder) { + // Use custom learning order for 月配列2-263 + const customWeight = TSUKI_2_263_LEARNING_ORDER.get(combo.codePoint); + if (customWeight != null) { + weights.set(combo.codePoint, customWeight); + } + } else { + // Default behavior for other layouts + switch (shape?.row) { + case "home": + weights.set(combo.codePoint, 1); + break; + case "top": + weights.set(combo.codePoint, 2); + break; + } } } } + + // For chord layouts, also include extended character array characters (slots 4-6) + for (const keyChars of this.characters.values()) { + if (keyChars.characters && keyChars.characters.length > 4) { + const shape = this.getShape(keyChars.id); + if (zones == null || shape?.inAnyZone(zones)) { + // Process chord layer characters (slots 4, 5, 6) + for (let i = 4; i < keyChars.characters.length; i++) { + const char = keyChars.characters[i]; + if (KeyCharacters.isCodePoint(char)) { + list.push(char); + + if (useTsukiLearningOrder) { + // Use custom learning order for chord characters + const customWeight = TSUKI_2_263_LEARNING_ORDER.get(char); + if (customWeight != null) { + weights.set(char, customWeight); + } + } else { + // Default weight for chord characters + weights.set(char, 3); + } + } + } + } + } + } + const codePoints = new Set(list.sort((a, b) => a - b)); return new (class implements WeightedCodePointSet { [Symbol.iterator](): IterableIterator { diff --git a/packages/keybr-keyboard/lib/keycharacters.ts b/packages/keybr-keyboard/lib/keycharacters.ts index 5acf6b772..e32296bdc 100644 --- a/packages/keybr-keyboard/lib/keycharacters.ts +++ b/packages/keybr-keyboard/lib/keycharacters.ts @@ -31,6 +31,8 @@ export class KeyCharacters { readonly b: Character | null; readonly c: Character | null; readonly d: Character | null; + /** Extended character array for chord layouts (optional, length 7+) */ + readonly characters?: readonly (Character | null)[]; constructor( id: KeyId, @@ -38,12 +40,14 @@ export class KeyCharacters { b: Character | null, c: Character | null, d: Character | null, + characters?: readonly (Character | null)[], ) { this.id = id; this.a = a || null; this.b = b || null; this.c = c || null; this.d = d || null; + this.characters = characters; } getCodePoint(modifier: KeyModifier): CodePoint | null { @@ -61,6 +65,40 @@ export class KeyCharacters { } } + /** + * Gets the code point for a chord layout key press. + * Supports extended character arrays with multiple chord layers. + * + * @param chordLayer The chord layer index (0 = base, 4 = ★, 5 = ☆, 6 = ※) + * @param modifier The key modifier (None, Shift, Alt, ShiftAlt) + * @returns The code point for the key+layer+modifier combination, or null + * + * Character array slots for chord layouts (length 7): + * [0] = None, [1] = Shift, [2] = Alt, [3] = ShiftAlt, + * [4] = ★ (KeyD), [5] = ☆ (KeyK), [6] = ※ (KeyF) + */ + getCodePointForChord( + chordLayer: number, + modifier: KeyModifier, + ): CodePoint | null { + // If no extended characters or base layer, use standard method + if (!this.characters || this.characters.length <= 4 || chordLayer === 0) { + return this.getCodePoint(modifier); + } + + // For chord layers, the layer index IS the base index + // Standard modifiers don't apply within chord layers for this layout + // (each chord layer only has one character per key) + const char = this.characters[chordLayer]; + + // Fallback: try base layer with modifier if chord layer is empty + if (char == null) { + return this.getCodePoint(modifier); + } + + return select(char); + } + get valid() { return Boolean(this.a || this.b || this.c || this.d); } diff --git a/packages/keybr-keyboard/lib/language.ts b/packages/keybr-keyboard/lib/language.ts index ce7337b32..068c7e097 100644 --- a/packages/keybr-keyboard/lib/language.ts +++ b/packages/keybr-keyboard/lib/language.ts @@ -200,7 +200,7 @@ export class Language implements EnumItem { Language.HR, Language.HU, Language.IT, - // Language.JA, + Language.JA, Language.LT, Language.LV, Language.NB, diff --git a/packages/keybr-keyboard/lib/layout.ts b/packages/keybr-keyboard/lib/layout.ts index 0b7b4befc..80c113093 100755 --- a/packages/keybr-keyboard/lib/layout.ts +++ b/packages/keybr-keyboard/lib/layout.ts @@ -1,7 +1,9 @@ import { Enum, XEnum, type XEnumItem } from "@keybr/lang"; import { Geometry } from "./geometry.ts"; import { Language } from "./language.ts"; +import { TSUKI_2_263_CHORD_METADATA } from "./layout/ja_tsuki_2_263.ts"; import { angleMod, angleWideMod, type Mod, nullMod } from "./mod.ts"; +import { type ChordLayoutMetadata } from "./types.ts"; export class Layout implements XEnumItem { static custom(language: Language) { @@ -1321,6 +1323,23 @@ export class Layout implements XEnumItem { /* emulate= */ false, /* geometries= */ new Enum(Geometry.ANSI_101), ); + static readonly JA_TSUKI_2_263 = new Layout( + /* id= */ "ja-tsuki-2-263", + /* xid= */ 0xba, + /* name= */ "月配列2-263", + /* family= */ "ja-tsuki", + /* language= */ Language.JA, + /* emulate= */ false, + /* geometries= */ new Enum( + Geometry.ANSI_101, + Geometry.ANSI_101_FULL, + Geometry.ISO_102, + Geometry.ISO_102_FULL, + Geometry.MATRIX, + ), + /* mod= */ nullMod, + /* chordMetadata= */ TSUKI_2_263_CHORD_METADATA, + ); static readonly ALL = new XEnum( Layout.EN_US, @@ -1384,6 +1403,7 @@ export class Layout implements XEnumItem { Layout.HU_HU, Layout.IT_IT, // Layout.JA_JP, + Layout.JA_TSUKI_2_263, Layout.LT_LT, Layout.LV_LV, Layout.NB_NO, @@ -1472,6 +1492,7 @@ export class Layout implements XEnumItem { readonly emulate: boolean, readonly geometries: Enum, readonly mod: Mod = nullMod, + readonly chordMetadata?: ChordLayoutMetadata, ) { Object.freeze(this); } diff --git a/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts b/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts new file mode 100644 index 000000000..c10e929c3 --- /dev/null +++ b/packages/keybr-keyboard/lib/layout/ja_tsuki_2_263.ts @@ -0,0 +1,232 @@ +// Generated file for 月配列2-263 (Tsuki-hairetsu 2-263) keyboard layout +// This layout uses chord-based input where KeyD and KeyK act as prefix shift modifier keys + +import { type CharacterDict, type ChordLayoutMetadata } from "../types.ts"; +import { type CodePoint } from "@keybr/unicode"; + +// Character array slots: [None, Shift, Alt, ShiftAlt, ★(KeyD), ☆(KeyK)] + +// prettier-ignore +export const LAYOUT_JA_TSUKI_2_263: CharacterDict = { + // Top row + Backquote: [null, null, null, null, null, null], + Digit1: [null, null, null, null, null, null], + Digit2: [null, null, null, null, null, null], + Digit3: [null, null, null, null, null, null], + Digit4: [null, null, null, null, null, null], + Digit5: [null, null, null, null, null, null], + Digit6: [null, null, null, null, null, null], + Digit7: [null, null, null, null, null, null], + Digit8: [null, null, null, null, null, null], + Digit9: [null, null, null, null, null, null], + Digit0: [null, null, null, null, null, null], + Minus: [null, null, null, null, null, null], + Equal: [null, null, null, null, null, null], + + // QWERTY row + KeyQ: [/* そ */ 0x305d, null, null, null, null, /* ぁ */ 0x3041], + KeyW: [/* こ */ 0x3053, null, null, null, null, /* ひ */ 0x3072], + KeyE: [/* し */ 0x3057, null, null, null, null, /* ほ */ 0x307b], + KeyR: [/* て */ 0x3066, null, null, null, null, /* ふ */ 0x3075], + KeyT: [/* ょ */ 0x3087, null, null, null, null, /* め */ 0x3081], + KeyY: [/* つ */ 0x3064, null, null, null, /* ぬ */ 0x306c, null], + KeyU: [/* ん */ 0x3093, null, null, null, /* え */ 0x3048, null], + KeyI: [/* い */ 0x3044, null, null, null, /* み */ 0x307f, null], + KeyO: [/* の */ 0x306e, null, null, null, /* や */ 0x3084, null], + KeyP: [/* り */ 0x308a, null, null, null, /* ぇ */ 0x3047, null], + BracketLeft: [/* ち */ 0x3061, null, null, null, null, null], + BracketRight: [null, null, null, null, null, null], + Backslash: [/* ・ */ 0x30fb, null, null, null, null, null], + + // ASDF row (home row) + KeyA: [/* は */ 0x306f, /* ば */ 0x3070, /* ぱ */ 0x3071, null, null, /* ぃ */ 0x3043], + KeyS: [/* か */ 0x304b, /* が */ 0x304c, null, null, null, /* を */ 0x3092], + KeyD: [/* ★ */ 0x3057, /* じ */ 0x3058, null, null, null, /* ら */ 0x3089], // ★ modifier key (also types し when used alone) + KeyF: [/* と */ 0x3068, /* ど */ 0x3069, null, null, null, /* あ */ 0x3042], // Normal key (k+f = あ) + KeyG: [/* た */ 0x305f, /* だ */ 0x3060, null, null, null, /* よ */ 0x3088], + KeyH: [/* く */ 0x304f, /* ぐ */ 0x3050, null, null, /* ま */ 0x307e, null], + KeyJ: [/* う */ 0x3046, null, null, null, /* お */ 0x304a, null], + KeyK: [/* の */ 0x306e, null, null, null, /* も */ 0x3082, null], // ☆ modifier key + KeyL: [/* ゛ */ 0x309b, /* ゛ */ 0x309b, null, null, /* わ */ 0x308f, null], // Also dakuten post-modifier + Semicolon: [/* き */ 0x304d, /* ぎ */ 0x304e, null, null, /* ゆ */ 0x3086, null], + Quote: [/* れ */ 0x308c, null, null, null, null, null], + + // ZXCV row + KeyZ: [/* す */ 0x3059, /* ず */ 0x305a, null, null, null, /* ぅ */ 0x3045], + KeyX: [/* け */ 0x3051, /* げ */ 0x3052, null, null, null, /* へ */ 0x3078], + KeyC: [/* に */ 0x306b, null, null, null, null, /* せ */ 0x305b], + KeyV: [/* な */ 0x306a, null, null, null, null, /* ゅ */ 0x3085], + KeyB: [/* さ */ 0x3055, /* ざ */ 0x3056, null, null, null, /* ゃ */ 0x3083], + KeyN: [/* っ */ 0x3063, null, null, null, /* む */ 0x3080, null], + KeyM: [/* る */ 0x308b, null, null, null, /* ろ */ 0x308d, null], + Comma: [/* 、 */ 0x3001, null, null, null, /* ね */ 0x306d, null], + Period: [/* 。 */ 0x3002, null, null, null, null, null], + Slash: [/* ゜ */ 0x309c, /* ゜ */ 0x309c, null, null, /* ぉ */ 0x3049, null], // Also handakuten post-modifier + + // Space + Space: [/* SPACE */ 0x0020, /* SPACE */ 0x0020, null, null, null, null], +}; + +// Dakuten (゛) conversion map: か→が, き→ぎ, etc. +const dakutenMap = new Map([ + // か行 → が行 + [0x304b, 0x304c], // か → が + [0x304d, 0x304e], // き → ぎ + [0x304f, 0x3050], // く → ぐ + [0x3051, 0x3052], // け → げ + [0x3053, 0x3054], // こ → ご + // さ行 → ざ行 + [0x3055, 0x3056], // さ → ざ + [0x3057, 0x3058], // し → じ + [0x3059, 0x305a], // す → ず + [0x305b, 0x305c], // せ → ぜ + [0x305d, 0x305e], // そ → ぞ + // た行 → だ行 + [0x305f, 0x3060], // た → だ + [0x3061, 0x3062], // ち → ぢ + [0x3064, 0x3065], // つ → づ + [0x3066, 0x3067], // て → で + [0x3068, 0x3069], // と → ど + // は行 → ば行 + [0x306f, 0x3070], // は → ば + [0x3072, 0x3073], // ひ → び + [0x3075, 0x3076], // ふ → ぶ + [0x3078, 0x3079], // へ → べ + [0x307b, 0x307c], // ほ → ぼ +]); + +// Handakuten (゜) conversion map: は→ぱ, ひ→ぴ, etc. +const handakutenMap = new Map([ + [0x306f, 0x3071], // は → ぱ + [0x3072, 0x3074], // ひ → ぴ + [0x3075, 0x3077], // ふ → ぷ + [0x3078, 0x307a], // へ → ぺ + [0x307b, 0x307d], // ほ → ぽ +]); + +/** + * Applies dakuten (゛) to a hiragana character. + * @param baseChar The base character to modify + * @returns The modified character with dakuten, or null if not applicable + */ +export function applyDakuten(baseChar: CodePoint): CodePoint | null { + return dakutenMap.get(baseChar) ?? null; +} + +/** + * Applies handakuten (゜) to a hiragana character. + * @param baseChar The base character to modify + * @returns The modified character with handakuten, or null if not applicable + */ +export function applyHandakuten(baseChar: CodePoint): CodePoint | null { + return handakutenMap.get(baseChar) ?? null; +} + +/** + * Metadata for the 月配列2-263 chord layout. + * Defines which keys act as chord modifiers and post-modifiers. + */ +export const TSUKI_2_263_CHORD_METADATA: ChordLayoutMetadata = { + chordModifiers: { + KeyD: 4, // ★ layer + KeyK: 5, // ☆ layer + }, + postModifiers: { + KeyL: applyDakuten, + Slash: applyHandakuten, + }, + layerCount: 6, +}; + +/** + * Custom learning order for 月配列2-263. + * Order: は、か、う、き、ば、が、ど、ぎスタート + progression + */ +export const TSUKI_2_263_LEARNING_ORDER = new Map([ + [0x306f, 1], // は + [0x304b, 2], // か + [0x3046, 3], // う + [0x304d, 4], // き + [0x3070, 5], // ば + [0x304c, 6], // が + [0x3069, 7], // ど + [0x304e, 8], // ぎ + [0x306e, 9], // の + [0x306b, 10], // に + [0x305f, 11], // た + [0x3044, 12], // い + [0x3092, 13], // を + [0x3068, 14], // と + [0x308b, 15], // る + [0x3057, 16], // し + [0x3067, 17], // で + [0x3066, 18], // て + [0x306a, 19], // な + [0x3063, 20], // っ + [0x308c, 21], // れ + [0x3089, 22], // ら + [0x3082, 23], // も + [0x3059, 24], // す + [0x308a, 25], // り + [0x3053, 26], // こ + [0x3060, 27], // だ + [0x307e, 28], // ま + [0x3055, 29], // さ + [0x3081, 30], // め + [0x304f, 31], // く + [0x3042, 32], // あ + [0x3051, 33], // け + [0x3093, 34], // ん + [0x3048, 35], // え + [0x3088, 36], // よ + [0x3064, 37], // つ + [0x3084, 38], // や + [0x305d, 39], // そ + [0x308f, 40], // わ + [0x3061, 41], // ち + [0x307f, 42], // み + [0x305b, 43], // せ + [0x308d, 44], // ろ + [0x304a, 45], // お + [0x3058, 46], // じ + [0x3079, 47], // べ + [0x305a, 48], // ず + [0x3052, 49], // げ + [0x307b, 50], // ほ + [0x3078, 51], // へ + [0x3073, 52], // び + [0x3080, 53], // む + [0x3054, 54], // ご + [0x306d, 55], // ね + [0x3076, 56], // ぶ + [0x3050, 57], // ぐ + [0x3072, 58], // ひ + [0x3087, 59], // ょ + [0x3065, 60], // づ + [0x307c, 61], // ぼ + [0x3056, 62], // ざ + [0x3075, 63], // ふ + [0x3083, 64], // ゃ + [0x305e, 65], // ぞ + [0x3086, 66], // ゆ + [0x305c, 67], // ぜ + [0x306c, 68], // ぬ + [0x3071, 69], // ぱ + [0x3085, 70], // ゅ + [0x3074, 71], // ぴ + [0x307d, 72], // ぽ + [0x3077, 73], // ぷ + [0x307a, 74], // ぺ + [0x3041, 75], // ぁ + [0x3047, 76], // ぇ + [0x3062, 77], // ぢ + // Additional characters + [0x3043, 78], // ぃ (A+☆) + [0x3045, 79], // ぅ (Z+☆) + [0x3049, 80], // ぉ (/+☆) + [0x309b, 81], // ゛ (L) + [0x309c, 82], // ゜ (/) + [0x3001, 83], // 、 (,) + [0x3002, 84], // 。 (.) + [0x30fb, 85], // ・ (\) +]); diff --git a/packages/keybr-keyboard/lib/load.ts b/packages/keybr-keyboard/lib/load.ts index a6439572c..f52201863 100755 --- a/packages/keybr-keyboard/lib/load.ts +++ b/packages/keybr-keyboard/lib/load.ts @@ -83,6 +83,7 @@ import { LAYOUT_HU_HU } from "./layout/hu_hu.ts"; import { LAYOUT_IT_IT } from "./layout/it_it.ts"; import { LAYOUT_JA_JP } from "./layout/ja_jp.ts"; import { LAYOUT_JA_JP_JIS } from "./layout/ja_jp_jis.ts"; +import { LAYOUT_JA_TSUKI_2_263 } from "./layout/ja_tsuki_2_263.ts"; import { LAYOUT_LT_LT } from "./layout/lt_lt.ts"; import { LAYOUT_LV_LV } from "./layout/lv_lv.ts"; import { LAYOUT_NB_DVORAK } from "./layout/nb_dvorak.ts"; @@ -184,6 +185,7 @@ const layouts = new Map([ [Layout.HU_HU, LAYOUT_HU_HU], [Layout.IT_IT, LAYOUT_IT_IT], [Layout.JA_JP, LAYOUT_JA_JP], + [Layout.JA_TSUKI_2_263, LAYOUT_JA_TSUKI_2_263], [Layout.LT_LT, LAYOUT_LT_LT], [Layout.LV_LV, LAYOUT_LV_LV], [Layout.NB_DVORAK, LAYOUT_NB_DVORAK], diff --git a/packages/keybr-keyboard/lib/types.ts b/packages/keybr-keyboard/lib/types.ts index 6c360c246..6a5ecc88d 100644 --- a/packages/keybr-keyboard/lib/types.ts +++ b/packages/keybr-keyboard/lib/types.ts @@ -41,6 +41,33 @@ export type CharacterDict = { readonly [id: KeyId]: readonly (Character | null)[]; }; +/** + * Metadata for chord-based keyboard layouts where certain keys act as modifiers. + * Used by layouts like 月配列 (Tsuki) that use chord/combo keys. + */ +export type ChordLayoutMetadata = { + /** + * Maps chord modifier keys to their layer index. + * Example: { KeyD: 4, KeyK: 5, KeyF: 6 } + * When KeyD is held, characters are looked up at index 4. + */ + readonly chordModifiers: { readonly [id: KeyId]: number }; + /** + * Maps post-modifier keys to their character transformation functions. + * Example: { KeyL: applyDakuten, Slash: applyHandakuten } + * When pressed after a character, transforms the previous character. + */ + readonly postModifiers: { + readonly [id: KeyId]: (char: CodePoint) => CodePoint | null; + }; + /** + * Total number of layers in the character arrays. + * Standard layouts: 4 (none, shift, alt, shift+alt) + * Chord layouts: 7+ (standard + chord layers) + */ + readonly layerCount: number; +}; + export type GeometryDict = { readonly [id: KeyId]: { readonly x: number; diff --git a/packages/keybr-lesson/lib/guided.ts b/packages/keybr-lesson/lib/guided.ts index c32472267..5b402441d 100644 --- a/packages/keybr-lesson/lib/guided.ts +++ b/packages/keybr-lesson/lib/guided.ts @@ -131,7 +131,19 @@ export class GuidedLesson extends Lesson { #getLetters() { const { letters } = this.model; const { codePoints } = this; - if (this.settings.get(lessonProps.guided.keyboardOrder)) { + // For 月配列2-263, always use keyboard order (custom learning progression) + const useTsukiOrder = this.keyboard.layout.id === "ja-tsuki-2-263"; + + // Debug logging (remove after verification) + if (useTsukiOrder) { + console.log("月配列2-263: カスタム学習順序を使用"); + const sampleWeights = letters.slice(0, 10).map(l => + `${String.fromCodePoint(l.codePoint)}:${codePoints.weight(l.codePoint)}` + ); + console.log("サンプル文字のweight:", sampleWeights.join(", ")); + } + + if (this.settings.get(lessonProps.guided.keyboardOrder) || useTsukiOrder) { return Letter.weightedFrequencyOrder(letters, ({ codePoint }) => codePoints.weight(codePoint), ); diff --git a/packages/keybr-textinput-events/lib/chord-emulation.ts b/packages/keybr-textinput-events/lib/chord-emulation.ts new file mode 100644 index 000000000..b24f44403 --- /dev/null +++ b/packages/keybr-textinput-events/lib/chord-emulation.ts @@ -0,0 +1,199 @@ +import { type Keyboard } from "@keybr/keyboard"; +import { type KeyId } from "@keybr/keyboard/lib/types.ts"; +import { toKeyModifier } from "./emulation.ts"; +import { PostModifierBuffer } from "./post-modifier.ts"; +import { type IInputEvent, type IKeyboardEvent, type InputListener } from "./types.ts"; + +/** + * Creates a chord-aware input listener for layouts like 月配列2-263. + * + * Handles: + * - Pre-modifier keys (d, k, f) that modify the NEXT character (prefix shift) + * - Post-modifier keys (l, /) that modify the previous character + * - Extended character arrays with 7+ slots + * + * NOTE: This implementation uses PREFIX SHIFT, not simultaneous chord detection. + * Pre-modifiers are pressed sequentially (d then k), not simultaneously. + * + * @param keyboard The keyboard with chord layout metadata + * @param target The target input listener to forward events to + * @returns A wrapped input listener with chord support + */ +export function chordEmulation( + keyboard: Keyboard, + target: InputListener, +): InputListener { + const postModBuffer = new PostModifierBuffer(); + const chordMeta = keyboard.layout.chordMetadata; + + if (!chordMeta) { + throw new Error("chordEmulation called on non-chord layout"); + } + + // State for pre-modifier (prefix shift) handling + // Support multiple pre-modifiers (e.g., K then D for combined layers) + const pendingPreModifiers: Map = new Map(); + const PRE_MODIFIER_TIMEOUT = 500; // ms - time window for sequential pre-modifier input + let preModifierTimeout: NodeJS.Timeout | null = null; + + // Check if a key is a pre-modifier (chord modifier key) + const isPreModifier = (code: KeyId): boolean => { + return chordMeta.chordModifiers[code] !== undefined; + }; + + // Clear pending pre-modifiers + const clearPendingPreModifiers = () => { + console.log('[TIMEOUT] Clearing pending pre-modifiers'); + pendingPreModifiers.clear(); + if (preModifierTimeout) { + clearTimeout(preModifierTimeout); + preModifierTimeout = null; + } + }; + + // Calculate combined chord layer from all pending pre-modifiers + const getCombinedChordLayer = (): number => { + if (pendingPreModifiers.size === 0) return 0; + + // Sum all pre-modifier layers + let combinedLayer = 0; + for (const [code, _] of pendingPreModifiers) { + const layer = chordMeta.chordModifiers[code] ?? 0; + combinedLayer += layer; + } + + console.log('[LAYER] Combined layer from', Array.from(pendingPreModifiers.keys()), '=', combinedLayer); + return combinedLayer; + }; + + return { + onKeyDown: (event: IKeyboardEvent): void => { + // Check if this is a post-modifier key + const postModFn = chordMeta.postModifiers[event.code]; + if (postModFn) { + // Clear pending pre-modifiers before post-modification + clearPendingPreModifiers(); + + const result = postModBuffer.tryModify(postModFn, event.timeStamp); + console.log('[POST-MOD] tryModify result:', result); + if (result.success && result.newChar !== null && result.oldChar !== null) { + console.log('[POST-MOD] Transforming', String.fromCodePoint(result.oldChar), '→', String.fromCodePoint(result.newChar)); + // Calculate the time from when the original character was typed to now + const timeToType = event.timeStamp - result.timeStamp; + console.log('[POST-MOD] Calculated timeToType:', timeToType, 'ms'); + + // Post-modification successful - emit clearChar to remove the old char from stats, then append new char + const clearEvent: IInputEvent = { + type: "input", + timeStamp: event.timeStamp, + inputType: "clearChar", + codePoint: result.oldChar, + timeToType: timeToType, + }; + console.log('[POST-MOD] Sending clearEvent for', String.fromCodePoint(result.oldChar), 'with timeToType:', timeToType); + target.onInput(clearEvent); + + const appendEvent: IInputEvent = { + type: "input", + timeStamp: event.timeStamp, + inputType: "appendChar", + codePoint: result.newChar, + timeToType: timeToType, + }; + console.log('[POST-MOD] Sending appendEvent for', String.fromCodePoint(result.newChar), 'with timeToType:', timeToType); + target.onInput(appendEvent); + } else { + console.log('[POST-MOD] Modification failed or invalid result'); + } + // Post-modification succeeded or failed - don't output the modifier character itself + // Pass through the keydown event and return + target.onKeyDown(event); + return; + } + + // Check if this key is a pre-modifier + if (isPreModifier(event.code)) { + // If there are already pending pre-modifiers, treat this key as a regular character key + // This allows K -> D to output "ら" (D at layer 5) + if (pendingPreModifiers.size > 0) { + console.log('[PRE-MOD] Pre-modifier', event.code, 'pressed with pending modifiers - treating as regular key'); + // Don't add to pending, fall through to regular key handling + } else { + // Add this pre-modifier to the pending set + pendingPreModifiers.set(event.code, { + timeStamp: event.timeStamp, + event, + }); + console.log('[PRE-MOD] Added pre-modifier:', event.code, '-> layer', chordMeta.chordModifiers[event.code], '| Total pending:', pendingPreModifiers.size); + + // Reset timeout - extend the window for sequential input + if (preModifierTimeout) { + clearTimeout(preModifierTimeout); + } + preModifierTimeout = setTimeout(clearPendingPreModifiers, PRE_MODIFIER_TIMEOUT); + + // Pass through the keydown event + target.onKeyDown(event); + return; + } + } + + // This is a regular character key (or pre-modifier treated as regular) - check if we have pending pre-modifiers + const chordLayer = getCombinedChordLayer(); + if (chordLayer > 0) { + console.log('[CHORD] Using combined chord layer', chordLayer, 'for key', event.code); + // Clear pending pre-modifiers (they've been consumed) + clearPendingPreModifiers(); + } + + // Get the character for this key + chord layer + modifiers + const characters = keyboard.getCharacters(event.code); + if (characters) { + const modifier = toKeyModifier(event.modifiers); + const codePoint = characters.getCodePointForChord(chordLayer, modifier); + console.log('[LOOKUP] Key:', event.code, 'Layer:', chordLayer, 'Modifier:', modifier, '-> CodePoint:', codePoint ? `${String.fromCodePoint(codePoint)} (U+${codePoint.toString(16).toUpperCase()})` : 'null'); + + if (codePoint !== null) { + // Record the character in the post-modifier buffer + postModBuffer.recordChar(codePoint, event.timeStamp); + + // Emit input event + const inputEvent: IInputEvent = { + type: "input", + timeStamp: event.timeStamp, + inputType: "appendChar", + codePoint, + timeToType: 0, + }; + target.onInput(inputEvent); + } + } + + // Pass through the keydown event + target.onKeyDown(event); + }, + + onKeyUp: (event: IKeyboardEvent): void => { + // Pre-modifiers are released but kept in pending state + // They will be cleared by timeout or consumed by the next regular key + if (isPreModifier(event.code)) { + console.log('[KEYUP] Pre-modifier released:', event.code, '| Pending:', Array.from(pendingPreModifiers.keys())); + } + + target.onKeyUp(event); + }, + + onInput: (event: IInputEvent): void => { + // Handle non-appendChar input events (like clearWord, etc.) + if (event.inputType !== "appendChar") { + if (event.inputType === "clearChar" || event.inputType === "clearWord") { + // Clear both buffers + postModBuffer.clear(); + clearPendingPreModifiers(); + } + target.onInput(event); + } + // appendChar events are generated by onKeyDown, so we skip them here + }, + }; +} diff --git a/packages/keybr-textinput-events/lib/emulation.ts b/packages/keybr-textinput-events/lib/emulation.ts index 6b5333f27..24eb72773 100644 --- a/packages/keybr-textinput-events/lib/emulation.ts +++ b/packages/keybr-textinput-events/lib/emulation.ts @@ -4,8 +4,10 @@ import { keyboardProps, KeyModifier, } from "@keybr/keyboard"; +import { type KeyId } from "@keybr/keyboard/lib/types.ts"; import { type Settings } from "@keybr/settings"; import { type CodePoint } from "@keybr/unicode"; +import { chordEmulation } from "./chord-emulation.ts"; import { isTextInput } from "./modifiers.ts"; import { TimeToType } from "./timetotype.ts"; import { @@ -18,7 +20,14 @@ export function emulateLayout( settings: Settings, keyboard: Keyboard, target: InputListener, + getDepressedKeys?: () => readonly KeyId[], ): InputListener { + // Check for chord layout first (uses prefix shift, not simultaneous detection) + if (keyboard.layout.chordMetadata) { + return chordEmulationWithTiming(keyboard, target); + } + + // Standard emulation for non-chord layouts if (keyboard.layout.emulate) { switch (settings.get(keyboardProps.emulation)) { case Emulation.Forward: @@ -30,6 +39,52 @@ export function emulateLayout( return target; } +/** + * Wraps chord emulation with timing measurement. + */ +function chordEmulationWithTiming( + keyboard: Keyboard, + target: InputListener, +): InputListener { + const timeToType = new TimeToType(); + const chordListener = chordEmulation(keyboard, { + onKeyDown: (event) => { + target.onKeyDown(event); + }, + onKeyUp: (event) => { + target.onKeyUp(event); + }, + onInput: (event) => { + // Add timing measurement to input events from chord emulation + if (event.inputType === "appendChar" || event.inputType === "clearChar") { + // If timeToType > 0, it's a post-modifier transformation with already calculated timing + // Otherwise, measure timing from keyboard events + const measuredTime = event.timeToType > 0 ? event.timeToType : timeToType.measure(event); + target.onInput({ + ...event, + timeToType: measuredTime, + }); + } else { + target.onInput(event); + } + }, + }); + + return { + onKeyDown: (event) => { + timeToType.add(event); + chordListener.onKeyDown(event); + }, + onKeyUp: (event) => { + timeToType.add(event); + chordListener.onKeyUp(event); + }, + onInput: (event) => { + chordListener.onInput(event); + }, + }; +} + /** * Expects the `code` property to be correct, changes the `key` property. * @@ -137,7 +192,7 @@ function fixCode( return { type, timeStamp, code, key, modifiers }; } -function toKeyModifier(modifiers: readonly ModifierId[]): KeyModifier { +export function toKeyModifier(modifiers: readonly ModifierId[]): KeyModifier { return KeyModifier.from( modifiers.includes("Shift"), modifiers.includes("AltGraph"), diff --git a/packages/keybr-textinput-events/lib/post-modifier.ts b/packages/keybr-textinput-events/lib/post-modifier.ts new file mode 100644 index 000000000..8defac42e --- /dev/null +++ b/packages/keybr-textinput-events/lib/post-modifier.ts @@ -0,0 +1,100 @@ +import { type CodePoint } from "@keybr/unicode"; + +/** + * Result of attempting to apply a post-modifier transformation. + */ +export type PostModifyResult = { + /** Whether the modification was successfully applied */ + readonly success: boolean; + /** The new modified character (if successful) or null */ + readonly newChar: CodePoint | null; + /** The old character that was replaced (if successful) or null */ + readonly oldChar: CodePoint | null; + /** The timestamp when the old character was originally typed */ + readonly timeStamp: number; +}; + +/** + * Buffer that tracks the last emitted character to allow post-modification. + * + * Used by chord layouts like 月配列2-263 to support dakuten (゛) and handakuten (゜) + * which modify the previously typed character rather than producing a new character. + * + * @example + * ```typescript + * const buffer = new PostModifierBuffer(); + * + * // Type か (ka) + * buffer.recordChar(0x304b); + * + * // Press dakuten key + * const result = buffer.tryModify(applyDakuten); + * // result.success === true + * // result.newChar === 0x304c (が) + * + * // Try dakuten on non-applicable character + * buffer.recordChar(0x3042); // あ (a) + * const result2 = buffer.tryModify(applyDakuten); + * // result2.success === false + * // result2.newChar === null + * ``` + */ +export class PostModifierBuffer { + private lastChar: CodePoint | null = null; + private lastTimeStamp: number = 0; + + /** + * Records a character that was just emitted. + * This character can be modified by subsequent post-modifier key presses. + * + * @param char The character code point to record + * @param timeStamp The timestamp when the character was typed + */ + recordChar(char: CodePoint, timeStamp: number): void { + this.lastChar = char; + this.lastTimeStamp = timeStamp; + } + + /** + * Attempts to apply a post-modification function to the last recorded character. + * + * @param modifierFn Function that transforms a character (e.g., adds dakuten) + * @param currentTimeStamp The timestamp when the modifier key was pressed + * @returns Result object with success flag, modified character, and original timestamp + */ + tryModify(modifierFn: (char: CodePoint) => CodePoint | null, currentTimeStamp: number): PostModifyResult { + // No previous character to modify + if (this.lastChar === null) { + return { success: false, newChar: null, oldChar: null, timeStamp: 0 }; + } + + // Try to apply the modification + const oldChar = this.lastChar; + const timeStamp = this.lastTimeStamp; + const modified = modifierFn(oldChar); + + if (modified !== null) { + // Modification successful - update the buffer with the new character + this.lastChar = modified; + this.lastTimeStamp = currentTimeStamp; + return { success: true, newChar: modified, oldChar, timeStamp }; + } + + // Modification not applicable to this character + return { success: false, newChar: null, oldChar: null, timeStamp: 0 }; + } + + /** + * Clears the buffer. Called when starting a new word or resetting input. + */ + clear(): void { + this.lastChar = null; + } + + /** + * Returns the last recorded character (for debugging/testing). + */ + getLastChar(): CodePoint | null { + return this.lastChar; + } +} diff --git a/packages/keybr-textinput/lib/textinput.test.ts b/packages/keybr-textinput/lib/textinput.test.ts index f4a517e93..ca3b0e87a 100644 --- a/packages/keybr-textinput/lib/textinput.test.ts +++ b/packages/keybr-textinput/lib/textinput.test.ts @@ -121,14 +121,14 @@ test("accumulate and delete garbage", () => { equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "*x|[a]|b|c"); equal(textInput.length, 3); equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "[a]|b|c"); equal(textInput.length, 3); @@ -175,14 +175,14 @@ test("handle backspace at the start of a word", () => { equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "[a]|b|c"); equal(textInput.length, 3); equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "[a]|b|c"); equal(textInput.length, 3); @@ -218,14 +218,14 @@ test("handle backspace in the middle of a word", () => { equal(textInput.pos, 1); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), "a,100,101"); equal(showChars(textInput), "a|[b]|c"); equal(textInput.length, 3); equal(textInput.pos, 1); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), "a,100,101"); equal(showChars(textInput), "a|[b]|c"); equal(textInput.length, 3); @@ -392,14 +392,14 @@ test("space in garbage", () => { equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "*x|[a]|b|c"); equal(textInput.length, 3); equal(textInput.pos, 0); isFalse(textInput.completed); - equal(textInput.clearChar(), Feedback.Succeeded); + equal(textInput.clearChar(0, 0, 0), Feedback.Succeeded); equal(showSteps(textInput), ""); equal(showChars(textInput), "[a]|b|c"); equal(textInput.length, 3); diff --git a/packages/keybr-textinput/lib/textinput.ts b/packages/keybr-textinput/lib/textinput.ts index 8ba893766..98e37d5d7 100644 --- a/packages/keybr-textinput/lib/textinput.ts +++ b/packages/keybr-textinput/lib/textinput.ts @@ -116,15 +116,79 @@ export class TextInput { case "appendLineBreak": return this.appendChar(timeStamp, 0x0020, timeToType); case "clearChar": - return this.clearChar(); + return this.clearChar(timeStamp, codePoint, timeToType); case "clearWord": return this.clearWord(); } } - clearChar(): Feedback { - this.#garbage.pop(); - this.#typo = true; + clearChar( + timeStamp: number, + codePoint: CodePoint, + timeToType: number, + ): Feedback { + const garbageItem = this.#garbage.pop(); + console.log('[TEXTINPUT clearChar] Removed from garbage:', garbageItem, 'timeToType:', timeToType); + + // For post-modifier transformations (dakuten/handakuten): + // There are two cases: + // 1. The base character was correctly typed (in steps) + // 2. The base character was a typo (in garbage) + + if (timeToType > 0) { + // This is a post-modifier transformation (timeToType > 0 is the indicator) + console.log('[TEXTINPUT clearChar] Post-modifier transformation detected'); + + if (garbageItem === undefined && this.#steps.length > 0) { + // Case 1: Base character was in steps (correctly typed) + const lastStep = this.#steps[this.#steps.length - 1]; + console.log('[TEXTINPUT clearChar] Base character from steps:', String.fromCodePoint(lastStep.codePoint)); + + // Update the timing to the actual measured time + const updatedStep = { + ...lastStep, + timeToType, + }; + + // Remove the step from the list (it will be replaced by the transformed character) + this.#steps.pop(); + + // Record the timing for statistics + console.log('[TEXTINPUT clearChar] Recording timing for base character:', timeToType, 'ms'); + this.onStep(updatedStep); + + // Don't set typo flag - this is a valid transformation + this.#typo = false; + } else if (garbageItem !== undefined) { + // Case 2: Base character was in garbage (typed as error) + console.log('[TEXTINPUT clearChar] Base character from garbage:', String.fromCodePoint(garbageItem.codePoint)); + + // Create a step with the actual timing for statistics + const step: Step = { + timeStamp: garbageItem.timeStamp, + codePoint: garbageItem.codePoint, + timeToType, + typo: false, // It's being transformed, so not counted as a typo + }; + + // Record the timing for statistics + console.log('[TEXTINPUT clearChar] Recording timing for garbage base character:', timeToType, 'ms'); + this.onStep(step); + + // Don't set typo flag - this is a valid transformation + this.#typo = false; + } + } else if (this.#garbage.length === 0 && garbageItem !== undefined) { + // Normal case: garbage had exactly one item which we just removed + console.log('[TEXTINPUT clearChar] Cleared one garbage item (normal backspace)'); + this.#typo = false; + } else if (this.#steps.length > 0) { + // Normal backspace - remove step and set typo flag + const removed = this.#steps.pop(); + console.log('[TEXTINPUT clearChar] Normal backspace - removed step:', removed); + this.#typo = true; + } + console.log('[TEXTINPUT clearChar] typo flag:', this.#typo, 'garbage length:', this.#garbage.length, 'pos:', this.pos); return this.#return(Feedback.Succeeded); } diff --git a/packages/page-practice/lib/practice/Controller.tsx b/packages/page-practice/lib/practice/Controller.tsx index 2c1ac0660..c0d8bc171 100644 --- a/packages/page-practice/lib/practice/Controller.tsx +++ b/packages/page-practice/lib/practice/Controller.tsx @@ -114,6 +114,7 @@ function useLessonState( timeout.schedule(handleResetLesson, 10000); }, }, + () => state.depressedKeys, ); return { state,