diff --git a/examples/react-stroke-graph/src/App.css b/examples/react-stroke-graph/src/App.css index b6594e6..6fe0bff 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,60 @@ 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; + 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 段下に配置し、全単語が見える幅を確保する */ +.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} -

- ) : ( - <> - )} -
+

+
); }