From 61304ebf7353369955d22e6ce59f3cf3d5689f8d Mon Sep 17 00:00:00 2001
From: tomoemon
Date: Sun, 17 May 2026 10:00:14 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat(examples):=20react-stroke-graph=20?=
=?UTF-8?q?=E3=81=AB=20preset=20rule/layout=20=E9=81=B8=E6=8A=9E=20UI=20?=
=?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
事前定義した rule・layout を入力完了ごとに自動ループする方式をやめ、
画面上で preset rule(全 9 種)・preset layout(全 9 種)・入力単語を
明示的に選択できるようにした。
- rule/layout/単語を draft 状態として保持し、適用ボタン押下でまとめて反映
- 適用時に automaton を再構築して打鍵状態をリセット
- 単語欄は空白区切りで複数指定でき、入力完了ごとに循環
- 英字・記号を含む単語に対応するため directInputRule を合成
- ルール名・ローマ字表記の表示を削除(選択欄・グラフと重複のため)
Co-Authored-By: Claude Opus 4.6
---
examples/react-stroke-graph/src/App.css | 39 +++-
examples/react-stroke-graph/src/App.tsx | 195 ++++++++++++++----
.../react-stroke-graph/src/typingGraph.tsx | 40 ++--
3 files changed, 205 insertions(+), 69 deletions(-)
diff --git a/examples/react-stroke-graph/src/App.css b/examples/react-stroke-graph/src/App.css
index b6594e6..d467d97 100644
--- a/examples/react-stroke-graph/src/App.css
+++ b/examples/react-stroke-graph/src/App.css
@@ -1,7 +1,7 @@
#root {
max-width: 1280px;
margin: 0 auto;
- padding: 2rem;
+ padding: 0.5rem 2rem;
text-align: center;
}
@@ -41,6 +41,43 @@
color: #888;
}
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.25rem 1rem;
+ margin-bottom: 0.5rem;
+}
+
+/* 入力対象ワードの表示 */
+.target-word {
+ margin: 0.25rem 0;
+ font-size: 1.5rem;
+ font-weight: bold;
+}
+
+.controls label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+}
+
+.controls button {
+ padding: 0.15rem 0.6rem;
+ font-size: 0.875rem;
+}
+
+/* 単語欄を改行して 1 段下に配置し、全単語が見える幅を確保する */
+.controls .words-field {
+ flex-basis: 100%;
+ justify-content: center;
+}
+
+.controls .words-field input {
+ width: 24rem;
+}
+
h1 {
margin: 0;
}
diff --git a/examples/react-stroke-graph/src/App.tsx b/examples/react-stroke-graph/src/App.tsx
index e62938c..7c0f2de 100644
--- a/examples/react-stroke-graph/src/App.tsx
+++ b/examples/react-stroke-graph/src/App.tsx
@@ -1,13 +1,27 @@
import cytoscape from "cytoscape";
import dagre from "cytoscape-dagre";
-import type { Automaton, KeyboardLayout } from "emiel";
+import type { Automaton, KeyboardLayout, Rule } from "emiel";
import {
build,
- detectKeyboardLayout,
+ createDirectInputRule,
+ loadPresetKeyboardLayoutAstarte,
+ loadPresetKeyboardLayoutColemak,
+ loadPresetKeyboardLayoutColemakDh,
+ loadPresetKeyboardLayoutDvorak,
+ loadPresetKeyboardLayoutEucalyn,
+ loadPresetKeyboardLayoutOnishi,
+ loadPresetKeyboardLayoutQwertyJis,
+ loadPresetKeyboardLayoutQwertyUs,
+ loadPresetKeyboardLayoutTomisukeJis,
+ loadPresetRuleAsuka123,
+ loadPresetRuleAsuka290,
+ loadPresetRuleAzikRomantable,
loadPresetRuleJisKana,
loadPresetRuleNaginatashikiV15,
loadPresetRuleNicola,
loadPresetRuleRoman,
+ loadPresetRuleShingeta,
+ loadPresetRuleTsuki2_263,
logging,
} from "emiel";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -19,59 +33,150 @@ logging.enable("keyboard.*", "automaton.*");
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
cytoscape.use(dagre);
+const rules: { name: string; load: (layout: KeyboardLayout) => Rule }[] = [
+ { name: "ローマ字", load: (layout) => loadPresetRuleRoman(layout) },
+ { name: "JISかな", load: () => loadPresetRuleJisKana() },
+ { name: "NICOLA", load: () => loadPresetRuleNicola() },
+ { name: "薙刀式", load: () => loadPresetRuleNaginatashikiV15() },
+ { name: "飛鳥123", load: () => loadPresetRuleAsuka123() },
+ { name: "飛鳥290", load: () => loadPresetRuleAsuka290() },
+ { name: "AZIK", load: (layout) => loadPresetRuleAzikRomantable(layout) },
+ { name: "新下駄", load: () => loadPresetRuleShingeta() },
+ { name: "月2-263", load: () => loadPresetRuleTsuki2_263() },
+];
+
+const layouts: { name: string; load: () => KeyboardLayout }[] = [
+ { name: "QWERTY JIS", load: loadPresetKeyboardLayoutQwertyJis },
+ { name: "QWERTY US", load: loadPresetKeyboardLayoutQwertyUs },
+ { name: "Dvorak", load: loadPresetKeyboardLayoutDvorak },
+ { name: "Colemak", load: loadPresetKeyboardLayoutColemak },
+ { name: "Colemak DH", load: loadPresetKeyboardLayoutColemakDh },
+ { name: "大西配列", load: loadPresetKeyboardLayoutOnishi },
+ { name: "eucalyn", load: loadPresetKeyboardLayoutEucalyn },
+ { name: "Astarte", load: loadPresetKeyboardLayoutAstarte },
+ { name: "とみすけ JIS", load: loadPresetKeyboardLayoutTomisukeJis },
+];
+
+const initialWordsText = "じょをひく しるため しょうがっこう";
+
function App() {
- const [layout, setLayout] = useState();
- useEffect(() => {
- detectKeyboardLayout(window).then(setLayout).catch(console.error);
- }, []);
- return layout ? : <>>;
-}
+ // 操作中の入力値(適用ボタンを押すまで反映されない)
+ const [draftRuleIndex, setDraftRuleIndex] = useState(0);
+ const [draftLayoutIndex, setDraftLayoutIndex] = useState(0);
+ const [draftWordsText, setDraftWordsText] = useState(initialWordsText);
+
+ // 適用済みの値(適用ボタンを押したタイミングで draft を反映する)
+ const [applied, setApplied] = useState({
+ ruleIndex: 0,
+ layoutIndex: 0,
+ wordsText: initialWordsText,
+ });
-function Typing(props: { layout: KeyboardLayout }) {
- const rules = useMemo(
- () => [
- { name: "ローマ字", rule: loadPresetRuleRoman(props.layout) },
- { name: "JISかな", rule: loadPresetRuleJisKana() },
- { name: "NICOLA", rule: loadPresetRuleNicola() },
- { name: "薙刀式", rule: loadPresetRuleNaginatashikiV15() },
- ],
- [props.layout],
- );
- const [automaton, setAutomaton] = useState(undefined);
- const [ruleName, setRuleName] = useState("");
const [wordIndex, setWordIndex] = useState(0);
- const [ruleIndex, setRuleIndex] = useState(0);
- const onFinished = useCallback(() => {
- const words = ["じょをひく", "しるため", "しょうがっこう"];
- const automaton = build(rules[ruleIndex].rule, words[wordIndex]);
- setAutomaton(automaton);
- setRuleName(rules[ruleIndex].name);
- if (wordIndex < words.length - 1) {
- setWordIndex(wordIndex + 1);
+ // 入力完了ごとに増やすカウンタ。単語が 1 つだけで wordIndex が変わらない場合でも
+ // automaton を作り直してループさせるために使う
+ const [round, setRound] = useState(0);
+ const [automaton, setAutomaton] = useState(undefined);
+
+ const layout = useMemo(() => layouts[applied.layoutIndex].load(), [applied.layoutIndex]);
+ // 英字・記号を含むワードにも対応できるよう、直接入力ルールを合成する
+ const rule = useMemo(
+ () => rules[applied.ruleIndex].load(layout).merge(createDirectInputRule(layout)),
+ [applied.ruleIndex, layout],
+ );
+ const words = useMemo(
+ () => applied.wordsText.trim().split(/\s+/).filter(Boolean),
+ [applied.wordsText],
+ );
+
+ // rule / layout / 単語が変わったら出題位置を先頭へ戻す(打鍵状態リセット)
+ useEffect(() => {
+ setWordIndex(0);
+ setRound(0);
+ }, [rule, words]);
+
+ // automaton を再構築する。新しい automaton インスタンスへの差し替えが打鍵状態リセットになる
+ useEffect(() => {
+ const word = words[wordIndex];
+ if (word !== undefined) {
+ setAutomaton(build(rule, word));
} else {
- setWordIndex(0);
- if (ruleIndex < rules.length - 1) {
- setRuleIndex(ruleIndex + 1);
- } else {
- setRuleIndex(0);
- }
+ setAutomaton(undefined);
}
- }, [ruleIndex, rules, wordIndex]);
- useEffect(() => {
- if (wordIndex === 0) {
- onFinished();
+ }, [rule, words, wordIndex, round]);
+
+ const onFinished = useCallback(() => {
+ setWordIndex((i) => (words.length > 0 ? (i + 1) % words.length : 0));
+ setRound((r) => r + 1);
+ }, [words.length]);
+
+ // 単語が入力済みで、かつ draft が適用済みの値から変更されている場合のみ適用できる
+ const isDirty =
+ draftRuleIndex !== applied.ruleIndex ||
+ draftLayoutIndex !== applied.layoutIndex ||
+ draftWordsText !== applied.wordsText;
+ const canApply = draftWordsText.trim().length > 0 && isDirty;
+ const apply = () => {
+ if (!canApply) {
+ return;
}
- }, [onFinished, wordIndex]);
+ setApplied({
+ ruleIndex: draftRuleIndex,
+ layoutIndex: draftLayoutIndex,
+ wordsText: draftWordsText,
+ });
+ };
+
return (
<>
+
+
+
+
+
{automaton ? (
-
+
) : (
- <>loading...>
+ <>単語を入力してください>
)}
>
);
diff --git a/examples/react-stroke-graph/src/typingGraph.tsx b/examples/react-stroke-graph/src/typingGraph.tsx
index 78e151a..a6bf5d3 100644
--- a/examples/react-stroke-graph/src/typingGraph.tsx
+++ b/examples/react-stroke-graph/src/typingGraph.tsx
@@ -2,20 +2,16 @@ import cytoscape from "cytoscape";
import dagre from "cytoscape-dagre";
import type { Automaton, InputStroke } from "emiel";
import { activate } from "emiel";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { buildGraphData } from "./graphData";
import { cyStylesheet } from "./grpahStyle";
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
cytoscape.use(dagre);
-export function TypingGraph(props: {
- automaton: Automaton;
- ruleName: string;
- onFinished: () => void;
-}) {
- const automaton = props.automaton;
- const graphData = buildGraphData(automaton.startNode);
+export function TypingGraph(props: { automaton: Automaton; onFinished: () => void }) {
+ const { automaton, onFinished } = props;
+ const graphData = useMemo(() => buildGraphData(automaton.startNode), [automaton]);
const htmlElem = useRef(null);
const [, setLastInputKey] = useState();
useEffect(() => {
@@ -39,7 +35,7 @@ export function TypingGraph(props: {
const result = automaton.input(e);
console.log(e.input.key.toString(), e.input.type, result);
if (result.isFinished) {
- props.onFinished();
+ onFinished();
} else if (result.isSucceeded) {
const nodeId = graphData.nodesMap.get(automaton.currentNode);
if (nodeId !== undefined) {
@@ -52,25 +48,23 @@ export function TypingGraph(props: {
}
}
});
- }, [props]);
+ }, [automaton, graphData, onFinished]);
const view = automaton.currentView();
- const finishedRomanSubstr = view.finishedRoman;
- const pendingRomanSubstr = view.pendingRoman;
return (
<>
- {props.ruleName}
-
+
{view.finishedWord} {view.pendingWord}
-
- {finishedRomanSubstr || pendingRomanSubstr ? (
-
- {finishedRomanSubstr} {pendingRomanSubstr}
-
- ) : (
- <>>
- )}
-
+
+
>
);
}
From c8cd25e1b4e2d9db29fb8308519c0ff0090dbc44 Mon Sep 17 00:00:00 2001
From: tomoemon
Date: Sun, 17 May 2026 10:03:06 +0900
Subject: [PATCH 2/2] =?UTF-8?q?feat(examples):=20=E9=81=A9=E7=94=A8?=
=?UTF-8?q?=E3=83=9C=E3=82=BF=E3=83=B3=E3=81=AE=E6=9C=89=E5=8A=B9/?=
=?UTF-8?q?=E7=84=A1=E5=8A=B9=E3=82=92=E8=A6=8B=E3=81=9F=E7=9B=AE=E3=81=A7?=
=?UTF-8?q?=E5=8C=BA=E5=88=A5=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86?=
=?UTF-8?q?=E9=85=8D=E8=89=B2=E3=82=92=E8=BF=BD=E5=8A=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.6
---
examples/react-stroke-graph/src/App.css | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/examples/react-stroke-graph/src/App.css b/examples/react-stroke-graph/src/App.css
index d467d97..6fe0bff 100644
--- a/examples/react-stroke-graph/src/App.css
+++ b/examples/react-stroke-graph/src/App.css
@@ -66,6 +66,23 @@
.controls button {
padding: 0.15rem 0.6rem;
font-size: 0.875rem;
+ border: 1px solid #646cff;
+ border-radius: 4px;
+ background-color: #646cff;
+ color: #fff;
+ cursor: pointer;
+}
+
+.controls button:hover:not(:disabled) {
+ background-color: #535bf2;
+ border-color: #535bf2;
+}
+
+.controls button:disabled {
+ border-color: #444;
+ background-color: #2a2a2a;
+ color: #777;
+ cursor: not-allowed;
}
/* 単語欄を改行して 1 段下に配置し、全単語が見える幅を確保する */