Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(client): switch input between word or sentence mode #668

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
199 changes: 199 additions & 0 deletions apps/client/components/main/QuestionInput/QuestionInputSentence.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<template>
<div class="text-center">
<div class="relative flex flex-wrap justify-center gap-2 transition-all">
<div
:class="getSentenceClassNames()"
class="h-[4rem] rounded-[2px] border-b-2 border-solid px-8 text-[3em] leading-none transition-all"
>
{{ inputValue }}
</div>
<input
ref="inputEl"
class="absolute h-full w-full opacity-0"
type="text"
v-model="inputValue"
@keydown="handleKeydown"
@focus="focusInput"
@blur="blurInput"
@dblclick.prevent
@mousedown="preventCursorMove"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
autoFocus
/>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";

import { courseTimer } from "~/composables/courses/courseTimer";
import { useAnswerTip } from "~/composables/main/answerTip";
import { useGameMode } from "~/composables/main/game";
import { useSentenceInput } from "~/composables/main/question";
import { useSummary } from "~/composables/main/summary";
import { useAutoNextQuestion } from "~/composables/user/autoNext";
import { useErrorTip } from "~/composables/user/errorTip";
import { useKeyboardSound } from "~/composables/user/sound";
import { useShowWordsWidth } from "~/composables/user/words";
import { useCourseStore } from "~/store/course";
import { useQuestionInput } from "./questionInputHelper";
import { usePlayTipSound, useTypingSound } from "./useTypingSound";

const courseStore = useCourseStore();
const { inputEl, focusing, focusInput, blurInput } = useQuestionInput();

const { showAnswer } = useGameMode();
const { showSummary } = useSummary();
const { isShowWordsWidth } = useShowWordsWidth();
const { isKeyboardSoundEnabled } = useKeyboardSound();
const { checkPlayTypingSound, playTypingSound } = useTypingSound();
const { playRightSound, playErrorSound } = usePlayTipSound();
const { handleAnswerError, resetCloseTip } = answerError();
const { isAutoNextQuestion } = useAutoNextQuestion();
const { isShowErrorTip } = useErrorTip();

const { inputValue, inputStatus, submitAnswer, setInputValue } = useSentenceInput(
() => courseStore.currentStatement?.english!,
);

const { showAnswerTip, hiddenAnswerTip } = useAnswerTip();

onMounted(() => {
focusInput();
resetCloseTip();
});

focusInputWhenWIndowFocus();

watch(
() => inputValue.value,
(val) => {
setInputValue(val);
courseTimer.time(String(courseStore.statementIndex));
},
);

watch(
() => courseStore.statementIndex,
() => {
focusInput();
resetCloseTip();
},
);

function focusInputWhenWIndowFocus() {
const handleFocus = () => {
focusInput();
};

onMounted(() => {
window.addEventListener("focus", handleFocus);
});

onUnmounted(() => {
window.removeEventListener("focus", handleFocus);
});
}

function getSentenceClassNames() {
// const words = useInputWords;
// let focus;
// for (let i = 0; i < words.length; i++) {
// const word = words[i];
// if (word.isActive) focus = true;
// if (word.incorrect) return "text-red-500 border-b-red-500";
// }
// if (focus) return "text-fuchsia-500 border-b-fuchsia-500";
// return "text-[#20202099] border-b-gray-300 dark:text-gray-300 dark:border-b-gray-400";
if (inputStatus.value === "wrong") {
return "text-red-500 border-b-red-500";
}
if (focusing.value) {
return "text-fuchsia-500 border-b-fuchsia-500";
}
return "text-[#20202099] border-b-gray-300 dark:text-gray-300 dark:border-b-gray-400";
}

function inputChangedCallback(e: KeyboardEvent) {
if (isKeyboardSoundEnabled() && checkPlayTypingSound(e)) {
playTypingSound();
}
}

function answerError() {
let wrongTimes = 0;

function handleAnswerError() {
playErrorSound();
wrongTimes++;
if (isShowErrorTip() && wrongTimes >= 3) {
showAnswerTip();
}
}

function resetCloseTip() {
wrongTimes = 0;
hiddenAnswerTip();
}

return {
handleAnswerError,
resetCloseTip,
};
}

function handleAnswerRight() {
courseTimer.timeEnd(String(courseStore.statementIndex)); // 停止当前题目的计时
playRightSound();

if (isAutoNextQuestion()) {
// 自动下一题
if (courseStore.isAllDone()) {
blurInput(); // 失去输入焦点,防止结束时光标仍然在输入框,造成后续结算面板回车事件无法触发
showSummary();
}
courseStore.toNextStatement();
} else {
showAnswer();
}
}

// 中文输入会导致先触发 handleKeydown
// 但是这时候字符还没有上屏
// 就会造成触发 submit answer 导致明明答案正确但是不通过的问题
// 通过检测是否为输入法 来避免按下 enter 后直接触发 submit answer
let isComposing = ref(false);
function handleCompositionStart() {
isComposing.value = true;
}

function handleCompositionEnd() {
isComposing.value = false;
}

function handleKeydown(e: KeyboardEvent) {
// 避免在某些中文输入法中,按下 Ctrl 键时,输入法会将当前的预输入字符上屏
if (e.ctrlKey) {
e.preventDefault();
return;
}

if (e.code === "Enter" && !isComposing.value) {
e.stopPropagation();
submitAnswer(handleAnswerRight, handleAnswerError);
return;
}

inputChangedCallback(e);
}

function preventCursorMove(event: MouseEvent) {
// 阻止 mousedown 事件的默认行为
// 它会改变 input 光标的位置
event.preventDefault();
// 只允许 input focus
focusInput();
}
</script>
Original file line number Diff line number Diff line change
@@ -1,18 +1,15 @@
<template>
<div class="text-center">
<div class="relative flex flex-wrap justify-center gap-2 transition-all">
<template
<div
v-for="(w, i) in courseStore.words"
:key="i"
class="h-[4rem] rounded-[2px] border-b-2 border-solid text-[3em] leading-none transition-all"
:class="getWordsClassNames(i)"
:style="{ minWidth: `${inputWidth(w)}ch` }"
>
<div
class="h-[4rem] rounded-[2px] border-b-2 border-solid text-[3em] leading-none transition-all"
:class="getWordsClassNames(i)"
:style="{ minWidth: `${inputWidth(w)}ch` }"
>
{{ userInputWords[i]["userInput"] }}
</div>
</template>
{{ userInputWords[i]["userInput"] }}
</div>
<input
ref="inputEl"
class="absolute h-full w-full opacity-0"
Expand All @@ -37,7 +34,7 @@ import { onMounted, onUnmounted, ref, watch } from "vue";
import { courseTimer } from "~/composables/courses/courseTimer";
import { useAnswerTip } from "~/composables/main/answerTip";
import { useGameMode } from "~/composables/main/game";
import { useInput } from "~/composables/main/question";
import { useWordsInput } from "~/composables/main/question";
import { useSummary } from "~/composables/main/summary";
import { useAutoNextQuestion } from "~/composables/user/autoNext";
import { useErrorTip } from "~/composables/user/errorTip";
Expand All @@ -64,7 +61,7 @@ const { isAutoNextQuestion } = useAutoNextQuestion();
const { isShowErrorTip } = useErrorTip();

const { inputValue, userInputWords, submitAnswer, setInputValue, handleKeyboardInput, isFixMode } =
useInput({
useWordsInput({
source: () => courseStore.currentStatement?.english!,
setInputCursorPosition,
getInputCursorPosition,
Expand Down
5 changes: 4 additions & 1 deletion apps/client/components/mode/chineseToEnglish/Question.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<div class="mb-4 mt-10 text-2xl dark:text-gray-50">
{{ courseStore.currentStatement?.chinese || "生存还是毁灭,这是一个问题" }}
</div>
<MainQuestionInput />
<MainQuestionInputWords v-if="isShowWordNumber()" />
<MainQuestionInputSentence v-else />
</div>
</template>

Expand All @@ -12,11 +13,13 @@ import { onMounted, watch } from "vue";

import { useCurrentStatementEnglishSound } from "~/composables/main/englishSound";
import { useAutoPlayEnglish } from "~/composables/user/sound";
import { useShowWordNumber } from "~/composables/user/wordNumber";
import { useCourseStore } from "~/store/course";

const courseStore = useCourseStore();
const { playSound } = useCurrentStatementEnglishSound();
const { isAutoPlayEnglish } = useAutoPlayEnglish();
const { isShowWordNumber } = useShowWordNumber();

onMounted(() => {
handleAutoPlayEnglish();
Expand Down
22 changes: 22 additions & 0 deletions apps/client/components/user/Setting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@
/>
</td>
</tr>

<tr class="hover">
<td class="label-text">
显示单词数量
<p class="label-warn">关闭此功能将禁用空格提交</p>
</td>
<td class="w-[300px] text-center">
<input
type="checkbox"
class="toggle toggle-secondary"
:checked="showWordNumber"
@change="toggleShowWordNumber"
/>
</td>
</tr>
</tbody>
</table>
</section>
Expand Down Expand Up @@ -234,6 +249,7 @@ import {
useKeyboardSound,
} from "~/composables/user/sound";
import { useSpaceSubmitAnswer } from "~/composables/user/submitKey";
import { useShowWordNumber } from "~/composables/user/wordNumber";
import { useShowWordsWidth } from "~/composables/user/words";
import { parseShortcutKeys } from "~/utils/keyboardShortcuts";

Expand Down Expand Up @@ -263,6 +279,7 @@ const {
togglePronunciation,
} = usePronunciation();
const { showWordsWidth, toggleAutoWordsWidth } = useShowWordsWidth();
const { showWordNumber, toggleShowWordNumber } = useShowWordNumber();
const { useSpace, toggleUseSpaceSubmitAnswer } = useSpaceSubmitAnswer();
const { showErrorTip, toggleShowErrorTip } = useErrorTip();
const {
Expand Down Expand Up @@ -315,4 +332,9 @@ onUnmounted(() => {
.btn-outline.btn-secondary {
@apply text-fuchsia-500 outline-fuchsia-500;
}

.label-warn {
color: rgb(192, 179, 37);
font-size: 13px;
}
</style>
47 changes: 44 additions & 3 deletions apps/client/composables/main/question.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Ref } from "vue";

import { nextTick, reactive, ref, watchEffect } from "vue";

interface Word {
Expand All @@ -11,7 +13,7 @@ interface Word {
id: number;
}

interface InputOptions {
interface WordsInputOptions {
source: () => string;
setInputCursorPosition: (position: number) => void;
getInputCursorPosition: () => number;
Expand All @@ -32,12 +34,12 @@ export function clearQuestionInput() {
inputValue.value = "";
}

export function useInput({
export function useWordsInput({
source,
setInputCursorPosition,
getInputCursorPosition,
inputChangedCallback,
}: InputOptions) {
}: WordsInputOptions) {
let mode: Mode = Mode.Input;
let currentEditWord: Word;

Expand Down Expand Up @@ -278,6 +280,7 @@ export function useInput({
}

function handleKeyboardInput(e: KeyboardEvent, options?: KeyboardInputOptions) {
console.log(e, inputValue);
// 禁止方向键移动
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
e.preventDefault();
Expand Down Expand Up @@ -356,3 +359,41 @@ export function useInput({
isFixMode,
};
}

export function useSentenceInput(source: () => string) {
let inputStatus: Ref<"correct" | "wrong" | "pending"> = ref("pending");

function setInputValue(val: string) {
inputValue.value = val;
inputStatus.value = "pending";
}

function formatInputText(word: string) {
return word
.toLocaleLowerCase()
.replace(/‘|’|“|"|”/g, "'")
.replace(/[\s]+/g, " ")
.trim();
}

function submitAnswer(correctCallback?: () => void, wrongCallback?: () => void) {
const english = source();
const formattedInput = formatInputText(inputValue.value);

if (formattedInput === english.toLocaleLowerCase()) {
inputStatus.value = "correct";
clearQuestionInput();
correctCallback?.();
} else {
inputStatus.value = "wrong";
wrongCallback?.();
}
}

return {
inputValue,
setInputValue,
submitAnswer,
inputStatus,
};
}
Loading
Loading