From ba1794a0724f559860cf1318a6b7324144f71270 Mon Sep 17 00:00:00 2001 From: tomoemon Date: Sun, 17 May 2026 14:19:55 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20examples=20=E3=81=AE=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=88=E3=83=A2=E3=83=BC=E3=83=89=E8=A6=96=E8=AA=8D=E6=80=A7?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=A8=E8=8B=B1=E6=95=B0=E5=AD=97=E6=B7=B7?= =?UTF-8?q?=E5=9C=A8=E3=83=AF=E3=83=BC=E3=83=89=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ライトモードで黄色系の色(枠線・ハイライト・ミスキー列)が白背景に埋もれて 見えづらかったため、グレーや赤などコントラストの取れる色へ変更した。 react-stroke-graph はカラースキームを監視してテーマに追従させる。 また英字・記号混在ワード "aから@" を扱う react-backspace / react-roman-or-kana で、ローマ字・かなルール単体では automaton 化できずエラーになっていたため、 createDirectInputRule をマージするよう修正した。 react-roman-or-kana では activate を打鍵ごとに再登録しており、その内部 KeyboardState がリセットされて Shift 押しっぱなしの状態が失われ、 "を"(Shift+0)が修飾なし入力として failed になっていた。ハンドラを マウント時に一度だけ登録し、最新 state は ref 経由で参照するよう修正した。 Co-Authored-By: Claude Opus 4.6 --- .../src/MissAccumulatingApp.tsx | 13 +- .../react-backspace/src/MissClearingApp.tsx | 13 +- .../react-backspace/src/MissCountingApp.tsx | 13 +- examples/react-keyboardguide/src/App.tsx | 10 +- examples/react-multi-word/src/word.tsx | 2 +- examples/react-roman-or-kana/src/App.tsx | 22 +++- examples/react-stroke-graph/src/grpahStyle.ts | 112 +++++++++--------- .../react-stroke-graph/src/typingGraph.tsx | 21 +++- 8 files changed, 124 insertions(+), 82 deletions(-) diff --git a/examples/react-backspace/src/MissAccumulatingApp.tsx b/examples/react-backspace/src/MissAccumulatingApp.tsx index f187a7f..b6fc11c 100644 --- a/examples/react-backspace/src/MissAccumulatingApp.tsx +++ b/examples/react-backspace/src/MissAccumulatingApp.tsx @@ -1,17 +1,20 @@ import type { InputStroke, KeyboardLayout } from "emiel"; -import { activate, build, loadPresetRuleRoman, VirtualKeys } from "emiel"; +import { activate, build, createDirectInputRule, loadPresetRuleRoman, VirtualKeys } from "emiel"; import { useEffect, useMemo, useState } from "react"; import { MissAccumulatingAutomaton } from "./MissAccumulatingAutomaton"; export function MissAccumulatingApp(props: { layout: KeyboardLayout }) { - const romanRule = useMemo(() => loadPresetRuleRoman(props.layout), [props.layout]); + const rule = useMemo( + () => loadPresetRuleRoman(props.layout).merge(createDirectInputRule(props.layout)), + [props.layout], + ); const words = useMemo(() => ["おをひく", "こんとん", "がっこう", "aから@"], []); const [index, setIndex] = useState(0); const [lastInputKey, setLastInputKey] = useState(); const wrappers = useMemo( - () => words.map((w) => new MissAccumulatingAutomaton(build(romanRule, w))), - [romanRule, words], + () => words.map((w) => new MissAccumulatingAutomaton(build(rule, w))), + [rule, words], ); const wrapper = wrappers[index]; @@ -52,7 +55,7 @@ export function MissAccumulatingApp(props: { layout: KeyboardLayout }) { > {view.pendingRoman}
- + {wrapper.failedInputs .map((f) => props.layout diff --git a/examples/react-backspace/src/MissClearingApp.tsx b/examples/react-backspace/src/MissClearingApp.tsx index 26bc26b..4a7bd8e 100644 --- a/examples/react-backspace/src/MissClearingApp.tsx +++ b/examples/react-backspace/src/MissClearingApp.tsx @@ -1,17 +1,20 @@ import type { InputStroke, KeyboardLayout } from "emiel"; -import { activate, build, loadPresetRuleRoman, VirtualKeys } from "emiel"; +import { activate, build, createDirectInputRule, loadPresetRuleRoman, VirtualKeys } from "emiel"; import { useEffect, useMemo, useState } from "react"; import { MissClearingAutomaton } from "./MissClearingAutomaton"; export function MissClearingApp(props: { layout: KeyboardLayout }) { - const romanRule = useMemo(() => loadPresetRuleRoman(props.layout), [props.layout]); + const rule = useMemo( + () => loadPresetRuleRoman(props.layout).merge(createDirectInputRule(props.layout)), + [props.layout], + ); const words = useMemo(() => ["おをひく", "こんとん", "がっこう", "aから@"], []); const [index, setIndex] = useState(0); const [lastInputKey, setLastInputKey] = useState(); const wrappers = useMemo( - () => words.map((w) => new MissClearingAutomaton(build(romanRule, w))), - [romanRule, words], + () => words.map((w) => new MissClearingAutomaton(build(rule, w))), + [rule, words], ); const wrapper = wrappers[index]; @@ -52,7 +55,7 @@ export function MissClearingApp(props: { layout: KeyboardLayout }) { > {view.pendingRoman}
- + {wrapper.failedInputs .map((f) => props.layout diff --git a/examples/react-backspace/src/MissCountingApp.tsx b/examples/react-backspace/src/MissCountingApp.tsx index dd47a06..3e3033f 100644 --- a/examples/react-backspace/src/MissCountingApp.tsx +++ b/examples/react-backspace/src/MissCountingApp.tsx @@ -1,17 +1,20 @@ import type { InputStroke, KeyboardLayout } from "emiel"; -import { activate, build, loadPresetRuleRoman, VirtualKeys } from "emiel"; +import { activate, build, createDirectInputRule, loadPresetRuleRoman, VirtualKeys } from "emiel"; import { useEffect, useMemo, useState } from "react"; import { MissCountingAutomaton } from "./MissCountingAutomaton"; export function MissCountingApp(props: { layout: KeyboardLayout }) { - const romanRule = useMemo(() => loadPresetRuleRoman(props.layout), [props.layout]); + const rule = useMemo( + () => loadPresetRuleRoman(props.layout).merge(createDirectInputRule(props.layout)), + [props.layout], + ); const words = useMemo(() => ["おをひく", "こんとん", "がっこう", "aから@"], []); const [index, setIndex] = useState(0); const [lastInputKey, setLastInputKey] = useState(); const wrappers = useMemo( - () => words.map((w) => new MissCountingAutomaton(build(romanRule, w))), - [romanRule, words], + () => words.map((w) => new MissCountingAutomaton(build(rule, w))), + [rule, words], ); const wrapper = wrappers[index]; @@ -52,7 +55,7 @@ export function MissCountingApp(props: { layout: KeyboardLayout }) { > {view.pendingRoman}
- + {wrapper.failedInputs .map((f) => props.layout diff --git a/examples/react-keyboardguide/src/App.tsx b/examples/react-keyboardguide/src/App.tsx index 8b01014..8d5c69f 100644 --- a/examples/react-keyboardguide/src/App.tsx +++ b/examples/react-keyboardguide/src/App.tsx @@ -232,10 +232,11 @@ function KeyCode(props: { placement: KeyPlacement; isKeyDowned: boolean }) { fontSize: "12pt", left: `${rect.x}px`, top: `${rect.y}px`, - border: "1px yellow solid", + border: "1px #888 solid", width: `${rect.width}px`, height: `${rect.height}px`, - backgroundColor: props.isKeyDowned ? "yellow" : "", + backgroundColor: props.isKeyDowned ? "#ffd000" : "", + color: props.isKeyDowned ? "#000" : undefined, }} > @@ -259,10 +260,11 @@ function KeyWithLabel(props: { placement: KeyPlacement; isKeyDowned: boolean }) fontSize: "12pt", left: `${rect.x}px`, top: `${rect.y}px`, - border: "1px yellow solid", + border: "1px #888 solid", width: `${rect.width}px`, height: `${rect.height}px`, - backgroundColor: props.isKeyDowned ? "yellow" : "", + backgroundColor: props.isKeyDowned ? "#ffd000" : "", + color: props.isKeyDowned ? "#000" : undefined, }} >
loadPresetRuleRoman(props.layout), [props.layout]); - const kanaRule = useMemo(() => loadPresetRuleJisKana(), []); + const directInputRule = useMemo(() => createDirectInputRule(props.layout), [props.layout]); + const romanRule = useMemo( + () => loadPresetRuleRoman(props.layout).merge(directInputRule), + [props.layout, directInputRule], + ); + const kanaRule = useMemo(() => loadPresetRuleJisKana().merge(directInputRule), [directInputRule]); const [selectors, setSelectors] = useState(() => words.map((w) => createCandidates(w, romanRule, kanaRule)), ); const [lastInputKey, setLastInputKey] = useState(); const [wordIndex, setWordIndex] = useState(0); + // activate を打鍵ごとに再登録すると、その内部 KeyboardState がリセットされ、 + // Shift など押しっぱなしの修飾キーの押下状態が失われてしまう(例: Shift を押した状態で + // 0 を打鍵しても「を」が修飾なし入力として failed になる)。 + // そのためハンドラはマウント時に一度だけ登録し、最新の state は ref 経由で参照する。 + const stateRef = useRef({ selectors, wordIndex }); + stateRef.current = { selectors, wordIndex }; useEffect(() => { return activate(window, (e) => { setLastInputKey(e.input); + const { selectors, wordIndex } = stateRef.current; if (e.input.key === VirtualKeys.Escape) { const reset = resetCandidates(selectors[wordIndex]); setSelectors((prev) => prev.map((c, i) => (i === wordIndex ? reset : c))); @@ -126,7 +138,9 @@ function Typing(props: { layout: KeyboardLayout }) { setSelectors((prev) => prev.map((c, i) => (i === wordIndex ? next : c))); } }); - }, [wordIndex, selectors, words.length]); + // マウント時に一度だけ登録する(依存配列は空) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const wordCandidates = selectors[wordIndex]; // finishedWord / pendingWord は word ベース表示で roman/kana どちらから diff --git a/examples/react-stroke-graph/src/grpahStyle.ts b/examples/react-stroke-graph/src/grpahStyle.ts index 9bebeff..c0bea86 100644 --- a/examples/react-stroke-graph/src/grpahStyle.ts +++ b/examples/react-stroke-graph/src/grpahStyle.ts @@ -1,60 +1,64 @@ import type { StylesheetStyle } from "cytoscape"; -export const cyStylesheet: StylesheetStyle[] = [ - { - selector: "node", - style: { - label: "data(label)", - width: 30, - height: 30, - backgroundColor: "white", - "border-color": "yellow", - "border-width": 2, - shape: "round-rectangle", - "text-valign": "center", - "text-halign": "center", +// ライト / ダーク両モードで視認できるよう、テーマに応じた色を返す +export function getCyStylesheet(dark: boolean): StylesheetStyle[] { + // 通常ノードはページ背景と同化しないよう、テーマと逆寄りの淡色で塗る + const nodeBackgroundColor = dark ? "#ffffff" : "#f0f0f0"; + // ノード番号やボーダーは背景とのコントラストを確保する + const nodeTextColor = dark ? "#000000" : "#213547"; + const borderColor = dark ? "#e6e600" : "#cccc00"; + // edge ラベル(ローマ字)は白固定だと light モードで消えるためテーマ追従させる + const edgeLabelColor = dark ? "#ffffff" : "#213547"; + + const nodeBase = { + label: "data(label)", + width: 30, + height: 30, + "border-color": borderColor, + "border-width": 2, + shape: "round-rectangle", + "text-valign": "center", + "text-halign": "center", + } as const; + + return [ + { + selector: "node", + style: { + ...nodeBase, + backgroundColor: nodeBackgroundColor, + color: nodeTextColor, + }, }, - }, - { - selector: "node.success", - style: { - label: "data(label)", - width: 30, - height: 30, - backgroundColor: "green", - "border-color": "yellow", - "border-width": 2, - shape: "round-rectangle", - "text-valign": "center", - "text-halign": "center", + { + selector: "node.success", + style: { + ...nodeBase, + backgroundColor: "green", + color: "#ffffff", + }, }, - }, - { - selector: "node.miss", - style: { - label: "data(label)", - width: 30, - height: 30, - backgroundColor: "red", - "border-color": "yellow", - "border-width": 2, - shape: "round-rectangle", - "text-valign": "center", - "text-halign": "center", + { + selector: "node.miss", + style: { + ...nodeBase, + backgroundColor: "red", + color: "#ffffff", + }, }, - }, - { - selector: "edge", - style: { - label: "data(label)", - color: "white", - width: 3, - "line-color": "green", - "text-margin-y": -10, - "arrow-scale": 1, - "curve-style": "bezier", - "target-arrow-color": "green", - "target-arrow-shape": "triangle", + { + selector: "edge", + style: { + label: "data(label)", + color: edgeLabelColor, + width: 3, + "line-color": "green", + "text-margin-y": -10, + "arrow-scale": 1, + "curve-style": "bezier", + "target-arrow-color": "green", + "target-arrow-shape": "triangle", + }, }, - }, -]; + ]; +} diff --git a/examples/react-stroke-graph/src/typingGraph.tsx b/examples/react-stroke-graph/src/typingGraph.tsx index a6bf5d3..60976b1 100644 --- a/examples/react-stroke-graph/src/typingGraph.tsx +++ b/examples/react-stroke-graph/src/typingGraph.tsx @@ -4,16 +4,29 @@ import type { Automaton, InputStroke } from "emiel"; import { activate } from "emiel"; import { useEffect, useMemo, useRef, useState } from "react"; import { buildGraphData } from "./graphData"; -import { cyStylesheet } from "./grpahStyle"; +import { getCyStylesheet } from "./grpahStyle"; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument cytoscape.use(dagre); +// OS のカラースキーム(ライト / ダーク)を監視し、変更に追従する +function usePrefersDark() { + const [dark, setDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches); + useEffect(() => { + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = (e: MediaQueryListEvent) => setDark(e.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + return dark; +} + 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(); + const dark = usePrefersDark(); useEffect(() => { if (htmlElem.current === null) { return; @@ -23,7 +36,7 @@ export function TypingGraph(props: { automaton: Automaton; onFinished: () => voi // @ts-expect-error rankDir is not defined in cytoscape layout: { name: "dagre", rankDir: "LR" }, userZoomingEnabled: false, - style: cyStylesheet, + style: getCyStylesheet(dark), }); cy.remove(cy.elements()); cy.add([...graphData.nodes, ...graphData.edges]); @@ -48,7 +61,7 @@ export function TypingGraph(props: { automaton: Automaton; onFinished: () => voi } } }); - }, [automaton, graphData, onFinished]); + }, [automaton, graphData, onFinished, dark]); const view = automaton.currentView(); return ( @@ -61,7 +74,7 @@ export function TypingGraph(props: { automaton: Automaton; onFinished: () => voi style={{ width: "600px", height: "300px", - border: "1px solid white", + border: "1px solid #888", margin: "0 auto", }} />