Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion examples/react-stroke-graph/src/App.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
padding: 0.5rem 2rem;
text-align: center;
}

Expand Down Expand Up @@ -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;
}
Expand Down
195 changes: 150 additions & 45 deletions examples/react-stroke-graph/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<KeyboardLayout | undefined>();
useEffect(() => {
detectKeyboardLayout(window).then(setLayout).catch(console.error);
}, []);
return layout ? <Typing layout={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<Automaton | undefined>(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<Automaton | undefined>(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 (
<>
<div className="controls">
<label>
入力ルール{" "}
<select
value={draftRuleIndex}
onChange={(e) => setDraftRuleIndex(Number(e.target.value))}
>
{rules.map((r, i) => (
<option key={r.name} value={i}>
{r.name}
</option>
))}
</select>
</label>
<label>
キーボードレイアウト{" "}
<select
value={draftLayoutIndex}
onChange={(e) => setDraftLayoutIndex(Number(e.target.value))}
>
{layouts.map((l, i) => (
<option key={l.name} value={i}>
{l.name}
</option>
))}
</select>
</label>
<label className="words-field">
<input
type="text"
value={draftWordsText}
onChange={(e) => setDraftWordsText(e.target.value)}
onKeyDown={(e) => {
// IME 変換確定の Enter は除外する
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
apply();
}
}}
/>
<button type="button" onClick={apply} disabled={!canApply}>
適用
</button>
</label>
</div>
{automaton ? (
<TypingGraph
automaton={automaton}
ruleName={ruleName}
onFinished={onFinished}
></TypingGraph>
<TypingGraph automaton={automaton} onFinished={onFinished}></TypingGraph>
) : (
<>loading...</>
<>単語を入力してください</>
)}
</>
);
Expand Down
40 changes: 17 additions & 23 deletions examples/react-stroke-graph/src/typingGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputStroke | undefined>();
useEffect(() => {
Expand All @@ -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) {
Expand All @@ -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 (
<>
<h2>{props.ruleName}</h2>
<h1>
<p className="target-word">
<span style={{ color: "gray" }}>{view.finishedWord}</span> {view.pendingWord}
</h1>
{finishedRomanSubstr || pendingRomanSubstr ? (
<h1>
<span style={{ color: "gray" }}>{finishedRomanSubstr}</span> {pendingRomanSubstr}
</h1>
) : (
<></>
)}
<div ref={htmlElem} style={{ width: "600px", height: "300px", border: "1px solid white" }} />
</p>
<div
ref={htmlElem}
style={{
width: "600px",
height: "300px",
border: "1px solid white",
margin: "0 auto",
}}
/>
</>
);
}