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 段下に配置し、全単語が見える幅を確保する */