Spaces:
Running
Running
Update index.html
Browse files- index.html +352 -102
index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title
|
| 7 |
<!-- 載入 Tailwind CSS CDN -->
|
| 8 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
<!-- 載入彩帶效果庫 -->
|
|
@@ -14,8 +14,6 @@
|
|
| 14 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
|
| 15 |
<!-- 載入 PDF.js 函式庫 -->
|
| 16 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script>
|
| 17 |
-
<!-- [第一步] 引入手寫辨識函式庫 -->
|
| 18 |
-
<script src="https://cdn.jsdelivr.net/gh/ChenYuHo/handwriting.js/handwriting.js"></script>
|
| 19 |
|
| 20 |
<!-- 【新】Firebase SDK -->
|
| 21 |
<script type="module">
|
|
@@ -127,16 +125,6 @@
|
|
| 127 |
#handwriting-container {
|
| 128 |
transition: all 0.3s ease;
|
| 129 |
}
|
| 130 |
-
#handwriting-canvas {
|
| 131 |
-
width: 100%;
|
| 132 |
-
max-width: 100%; /* 確保不超出容器 */
|
| 133 |
-
height: 250px; /* 手寫區域高度 */
|
| 134 |
-
background-color: #fff;
|
| 135 |
-
border: 2px dashed #cbd5e1; /* 虛線邊框更有「畫布」的感覺 */
|
| 136 |
-
border-radius: 0.75rem;
|
| 137 |
-
cursor: crosshair;
|
| 138 |
-
touch-action: none; /* [重要] 防止手指寫字時觸發網頁捲動 */
|
| 139 |
-
}
|
| 140 |
/* 讓答案框在唯讀模式下看起來像「顯示區」 */
|
| 141 |
input[readonly].handwriting-mode {
|
| 142 |
background-color: #f3f4f6;
|
|
@@ -145,10 +133,68 @@
|
|
| 145 |
border-color: #818cf8;
|
| 146 |
cursor: not-allowed;
|
| 147 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
</style>
|
| 149 |
</head>
|
| 150 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans">
|
| 151 |
|
|
|
|
|
|
|
|
|
|
| 152 |
<!-- 主選單:新增單字 & 模式選擇 -->
|
| 153 |
<div id="main-menu" class="w-full max-w-2xl bg-white p-6 rounded-3xl shadow-2xl transition-all duration-500">
|
| 154 |
<h1 id="main-title" class="text-4xl font-extrabold text-center text-gray-900 mb-6"></h1>
|
|
@@ -369,11 +415,14 @@
|
|
| 369 |
<input id="answer-input" placeholder="輸入你的答案 (按 Enter 提交)" class="w-full p-4 border-2 border-gray-300 rounded-xl text-2xl focus:border-indigo-500 focus:ring-indigo-500 transition-colors" autocomplete="off">
|
| 370 |
|
| 371 |
<div id="handwriting-container" class="hidden flex-col gap-2">
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
</div>
|
| 376 |
|
|
|
|
|
|
|
|
|
|
| 377 |
<div class="flex gap-2 w-full">
|
| 378 |
<button id="hw-clear-btn" class="flex-1 py-3 bg-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-300 transition-colors flex items-center justify-center gap-1">
|
| 379 |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" /></svg>
|
|
@@ -613,7 +662,12 @@
|
|
| 613 |
<script>
|
| 614 |
// [第四步] JavaScript 邏輯 (Script)
|
| 615 |
let effectiveVersion = 'pc'; // 預設為 PC 版
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
document.addEventListener('DOMContentLoaded', () => {
|
| 619 |
// DOM 元素
|
|
@@ -701,6 +755,12 @@
|
|
| 701 |
// 為了配合新版介面,建議將提示顯示區域也移動到新區塊內,但為了最小修改,我們這裡用 JS 控制
|
| 702 |
const quizCompletionMessage = document.getElementById('quiz-completion-message');
|
| 703 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
// 複習模式導覽
|
| 705 |
const reviewNavContainer = document.getElementById('review-nav-container');
|
| 706 |
const prevBtn = document.getElementById('prev-btn');
|
|
@@ -796,109 +856,302 @@
|
|
| 796 |
'sentence-cloze': { title: '情境克漏字' },
|
| 797 |
};
|
| 798 |
|
| 799 |
-
// [新增]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
function initHandwritingBoard() {
|
| 801 |
-
|
| 802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
-
//
|
| 805 |
-
|
|
|
|
| 806 |
|
| 807 |
-
|
| 808 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
};
|
| 818 |
-
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
return;
|
| 831 |
}
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
// 視覺回饋
|
| 838 |
-
answerInput.classList.add('border-green-500', 'bg-green-50');
|
| 839 |
-
setTimeout(() => answerInput.classList.remove('border-green-500', 'bg-green-50'), 500);
|
| 840 |
-
}
|
| 841 |
-
});
|
| 842 |
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
|
| 854 |
-
|
| 855 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
|
|
|
|
|
|
|
|
|
| 865 |
});
|
| 866 |
}
|
| 867 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
// [新增] 更新輸入介面 (控制手寫板顯示與輸入法)
|
| 869 |
-
// expectedLang: 'en' (預期輸入英文) 或 'zh' (預期輸入中文)
|
| 870 |
function updateInputInterface(expectedLang) {
|
| 871 |
const hwContainer = document.getElementById('handwriting-container');
|
| 872 |
|
| 873 |
-
//
|
| 874 |
-
|
|
|
|
| 875 |
|
| 876 |
if (shouldUseHandwriting) {
|
| 877 |
-
// ---
|
| 878 |
-
// 1.
|
| 879 |
hwContainer.classList.remove('hidden');
|
| 880 |
hwContainer.classList.add('flex');
|
| 881 |
|
| 882 |
-
// 2.
|
| 883 |
-
|
|
|
|
| 884 |
|
| 885 |
-
// 3. 鎖定輸入框
|
| 886 |
answerInput.readOnly = true;
|
| 887 |
answerInput.placeholder = "請在下方手寫英文...";
|
| 888 |
answerInput.classList.add('handwriting-mode');
|
| 889 |
|
| 890 |
-
// 4.
|
| 891 |
-
if (
|
| 892 |
-
|
| 893 |
}
|
| 894 |
|
| 895 |
} else {
|
| 896 |
-
// --- PC
|
| 897 |
-
// 1. 隱藏手寫板
|
| 898 |
hwContainer.classList.add('hidden');
|
| 899 |
hwContainer.classList.remove('flex');
|
| 900 |
|
| 901 |
-
// 2. 恢復輸入框
|
| 902 |
answerInput.readOnly = false;
|
| 903 |
answerInput.placeholder = (expectedLang === 'zh') ? "輸入中文答案 (按 Enter 提交)" : "輸入英文答案 (按 Enter 提交)";
|
| 904 |
answerInput.classList.remove('handwriting-mode');
|
|
@@ -1213,7 +1466,7 @@
|
|
| 1213 |
if (mode === 'sentence-cloze') {
|
| 1214 |
wordsForCurrentMode = wordPool.filter(w => w.sentence && w.sentence.en && w.sentence.zh && w.sentence.answer);
|
| 1215 |
if (wordsForCurrentMode.length === 0) {
|
| 1216 |
-
|
| 1217 |
return;
|
| 1218 |
}
|
| 1219 |
} else if (mode === 'review') {
|
|
@@ -1223,7 +1476,7 @@
|
|
| 1223 |
if (!isNaN(randomCount) && randomCount > 0 && randomCount <= wordPool.length) {
|
| 1224 |
wordsForCurrentMode = shuffleArray([...wordPool]).slice(0, randomCount);
|
| 1225 |
} else {
|
| 1226 |
-
|
| 1227 |
return;
|
| 1228 |
}
|
| 1229 |
} else {
|
|
@@ -1231,7 +1484,7 @@
|
|
| 1231 |
}
|
| 1232 |
|
| 1233 |
if (wordsForCurrentMode.length === 0) {
|
| 1234 |
-
|
| 1235 |
return;
|
| 1236 |
}
|
| 1237 |
|
|
@@ -1239,7 +1492,7 @@
|
|
| 1239 |
const hardWords = words.filter(w => w.incorrectCount > 0)
|
| 1240 |
.sort((a, b) => b.incorrectCount - a.incorrectCount).slice(0, 10);
|
| 1241 |
if (hardWords.length === 0) {
|
| 1242 |
-
|
| 1243 |
}
|
| 1244 |
wordsForCurrentMode = hardWords;
|
| 1245 |
quizQueue = shuffleArray([...wordsForCurrentMode]);
|
|
@@ -1332,9 +1585,8 @@
|
|
| 1332 |
feedbackDisplay.classList.add('text-green-600');
|
| 1333 |
triggerConfetti();
|
| 1334 |
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
}
|
| 1338 |
|
| 1339 |
if (!flashcardContainer.classList.contains('flipped')) {
|
| 1340 |
flashcardContainer.classList.add('flipped');
|
|
@@ -1435,10 +1687,8 @@
|
|
| 1435 |
const wrongCard = quizQueue.shift();
|
| 1436 |
const reinsertIndex = Math.min(quizQueue.length, 3);
|
| 1437 |
quizQueue.splice(reinsertIndex, 0, wrongCard);
|
| 1438 |
-
//
|
| 1439 |
-
|
| 1440 |
-
handwritingCanvasObj.erase();
|
| 1441 |
-
}
|
| 1442 |
displayCard();
|
| 1443 |
});
|
| 1444 |
|
|
@@ -1706,7 +1956,7 @@
|
|
| 1706 |
async function callGeminiToParseText(text) {
|
| 1707 |
const apiKey = apiKeyInput.value.trim();
|
| 1708 |
if (!apiKey) {
|
| 1709 |
-
|
| 1710 |
parsePdfBtn.disabled = false;
|
| 1711 |
aiProgressContainer.classList.add('hidden');
|
| 1712 |
return;
|
|
@@ -2010,7 +2260,7 @@
|
|
| 2010 |
saveTitleBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700');
|
| 2011 |
}, 2000);
|
| 2012 |
} else {
|
| 2013 |
-
|
| 2014 |
}
|
| 2015 |
});
|
| 2016 |
|
|
@@ -2038,7 +2288,7 @@
|
|
| 2038 |
const { collection, addDoc } = window.firebaseFirestore;
|
| 2039 |
|
| 2040 |
if (!db) {
|
| 2041 |
-
|
| 2042 |
generateLinkBtn.textContent = '產生分享連結';
|
| 2043 |
generateLinkBtn.disabled = false;
|
| 2044 |
return;
|
|
@@ -2069,7 +2319,7 @@
|
|
| 2069 |
|
| 2070 |
} catch (error) {
|
| 2071 |
console.error("分享至 Firebase 失敗:", error);
|
| 2072 |
-
|
| 2073 |
} finally {
|
| 2074 |
generateLinkBtn.textContent = '重新產生';
|
| 2075 |
generateLinkBtn.disabled = false;
|
|
@@ -2163,7 +2413,7 @@
|
|
| 2163 |
}, 2000);
|
| 2164 |
} else {
|
| 2165 |
localStorage.removeItem('geminiApiKey');
|
| 2166 |
-
|
| 2167 |
}
|
| 2168 |
});
|
| 2169 |
|
|
@@ -2263,11 +2513,11 @@
|
|
| 2263 |
loadedFromUrl = true;
|
| 2264 |
}
|
| 2265 |
} else {
|
| 2266 |
-
|
| 2267 |
}
|
| 2268 |
} catch (error) {
|
| 2269 |
console.error("從 Firebase 讀取資料失敗:", error);
|
| 2270 |
-
|
| 2271 |
}
|
| 2272 |
}
|
| 2273 |
}
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>單字閃卡 (手寫測試版)</title>
|
| 7 |
<!-- 載入 Tailwind CSS CDN -->
|
| 8 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 9 |
<!-- 載入彩帶效果庫 -->
|
|
|
|
| 14 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"></script>
|
| 15 |
<!-- 載入 PDF.js 函式庫 -->
|
| 16 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.11.338/pdf.min.js"></script>
|
|
|
|
|
|
|
| 17 |
|
| 18 |
<!-- 【新】Firebase SDK -->
|
| 19 |
<script type="module">
|
|
|
|
| 125 |
#handwriting-container {
|
| 126 |
transition: all 0.3s ease;
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
/* 讓答案框在唯讀模式下看起來像「顯示區」 */
|
| 129 |
input[readonly].handwriting-mode {
|
| 130 |
background-color: #f3f4f6;
|
|
|
|
| 133 |
border-color: #818cf8;
|
| 134 |
cursor: not-allowed;
|
| 135 |
}
|
| 136 |
+
|
| 137 |
+
/* 觸摸手寫板時的視覺回饋類別 */
|
| 138 |
+
.canvas-active {
|
| 139 |
+
border-color: #6366f1 !important; /* Indigo-500 */
|
| 140 |
+
background-color: #f0fdf4 !important; /* Green-50 */
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* 候選字列樣式 */
|
| 144 |
+
#candidate-bar {
|
| 145 |
+
display: flex;
|
| 146 |
+
gap: 0.5rem;
|
| 147 |
+
overflow-x: auto;
|
| 148 |
+
padding: 0.5rem 0;
|
| 149 |
+
margin-top: 0.5rem;
|
| 150 |
+
min-height: 40px;
|
| 151 |
+
}
|
| 152 |
+
.candidate-btn {
|
| 153 |
+
background-color: #e0e7ff;
|
| 154 |
+
color: #4338ca;
|
| 155 |
+
padding: 0.25rem 0.75rem;
|
| 156 |
+
border-radius: 9999px;
|
| 157 |
+
font-weight: 600;
|
| 158 |
+
white-space: nowrap;
|
| 159 |
+
border: 1px solid #c7d2fe;
|
| 160 |
+
transition: all 0.2s;
|
| 161 |
+
}
|
| 162 |
+
.candidate-btn:hover {
|
| 163 |
+
background-color: #c7d2fe;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* [新增] Toast 通知樣式 (取代 alert) */
|
| 167 |
+
#toast {
|
| 168 |
+
visibility: hidden;
|
| 169 |
+
min-width: 280px;
|
| 170 |
+
background-color: rgba(31, 41, 55, 0.95); /* Gray-900 with opacity */
|
| 171 |
+
color: #fff;
|
| 172 |
+
text-align: center;
|
| 173 |
+
border-radius: 12px;
|
| 174 |
+
padding: 16px;
|
| 175 |
+
position: fixed;
|
| 176 |
+
z-index: 100;
|
| 177 |
+
left: 50%;
|
| 178 |
+
bottom: 30px;
|
| 179 |
+
transform: translateX(-50%);
|
| 180 |
+
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
| 181 |
+
font-size: 16px;
|
| 182 |
+
font-weight: 500;
|
| 183 |
+
opacity: 0;
|
| 184 |
+
transition: opacity 0.3s, bottom 0.3s;
|
| 185 |
+
}
|
| 186 |
+
#toast.show {
|
| 187 |
+
visibility: visible;
|
| 188 |
+
opacity: 1;
|
| 189 |
+
bottom: 50px;
|
| 190 |
+
}
|
| 191 |
</style>
|
| 192 |
</head>
|
| 193 |
<body class="bg-gray-50 min-h-screen flex flex-col items-center p-4 sm:p-8 font-sans">
|
| 194 |
|
| 195 |
+
<!-- [新增] Toast 通知元素 -->
|
| 196 |
+
<div id="toast"></div>
|
| 197 |
+
|
| 198 |
<!-- 主選單:新增單字 & 模式選擇 -->
|
| 199 |
<div id="main-menu" class="w-full max-w-2xl bg-white p-6 rounded-3xl shadow-2xl transition-all duration-500">
|
| 200 |
<h1 id="main-title" class="text-4xl font-extrabold text-center text-gray-900 mb-6"></h1>
|
|
|
|
| 415 |
<input id="answer-input" placeholder="輸入你的答案 (按 Enter 提交)" class="w-full p-4 border-2 border-gray-300 rounded-xl text-2xl focus:border-indigo-500 focus:ring-indigo-500 transition-colors" autocomplete="off">
|
| 416 |
|
| 417 |
<div id="handwriting-container" class="hidden flex-col gap-2">
|
| 418 |
+
<!-- 使用 wrapper 包裹 canvas,並強制設定固定高度,防止高度塌陷 -->
|
| 419 |
+
<div id="canvas-wrapper" class="relative w-full h-[250px]">
|
| 420 |
+
<!-- Canvas 將會由 JS 動態生成插入 -->
|
| 421 |
</div>
|
| 422 |
|
| 423 |
+
<!-- 候選字列 -->
|
| 424 |
+
<div id="candidate-bar" class="hidden"></div>
|
| 425 |
+
|
| 426 |
<div class="flex gap-2 w-full">
|
| 427 |
<button id="hw-clear-btn" class="flex-1 py-3 bg-gray-200 text-gray-700 rounded-xl font-semibold hover:bg-gray-300 transition-colors flex items-center justify-center gap-1">
|
| 428 |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" /></svg>
|
|
|
|
| 662 |
<script>
|
| 663 |
// [第四步] JavaScript 邏輯 (Script)
|
| 664 |
let effectiveVersion = 'pc'; // 預設為 PC 版
|
| 665 |
+
|
| 666 |
+
// [修改] 移除第三方庫物件,改用原生變數
|
| 667 |
+
let isDrawing = false;
|
| 668 |
+
let currentStrokes = []; // 存放所有筆畫: [ [[x], [y], [t]], [[x], [y], [t]], ... ]
|
| 669 |
+
let currentStroke = {x: [], y: [], t: []}; // 暫存當前筆畫
|
| 670 |
+
let canvasCtx = null;
|
| 671 |
|
| 672 |
document.addEventListener('DOMContentLoaded', () => {
|
| 673 |
// DOM 元素
|
|
|
|
| 755 |
// 為了配合新版介面,建議將提示顯示區域也移動到新區塊內,但為了最小修改,我們這裡用 JS 控制
|
| 756 |
const quizCompletionMessage = document.getElementById('quiz-completion-message');
|
| 757 |
|
| 758 |
+
// 手寫功能按鈕
|
| 759 |
+
const hwClearBtn = document.getElementById('hw-clear-btn');
|
| 760 |
+
const hwUndoBtn = document.getElementById('hw-undo-btn');
|
| 761 |
+
const hwRecognizeBtn = document.getElementById('hw-recognize-btn');
|
| 762 |
+
const candidateBar = document.getElementById('candidate-bar');
|
| 763 |
+
|
| 764 |
// 複習模式導覽
|
| 765 |
const reviewNavContainer = document.getElementById('review-nav-container');
|
| 766 |
const prevBtn = document.getElementById('prev-btn');
|
|
|
|
| 856 |
'sentence-cloze': { title: '情境克漏字' },
|
| 857 |
};
|
| 858 |
|
| 859 |
+
// [新增] Toast 通知函式
|
| 860 |
+
function showToast(message) {
|
| 861 |
+
const toast = document.getElementById('toast');
|
| 862 |
+
toast.textContent = message;
|
| 863 |
+
toast.classList.add('show');
|
| 864 |
+
setTimeout(() => {
|
| 865 |
+
toast.classList.remove('show');
|
| 866 |
+
}, 3000);
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
// [修改] 核心重寫:初始化手寫板功能 (使用原生 Canvas API)
|
| 870 |
function initHandwritingBoard() {
|
| 871 |
+
const wrapper = document.getElementById('canvas-wrapper');
|
| 872 |
+
if (!wrapper) return;
|
| 873 |
+
|
| 874 |
+
// 1. 確保 wrapper 可見且有寬度
|
| 875 |
+
const rect = wrapper.getBoundingClientRect();
|
| 876 |
+
if (rect.width < 50) {
|
| 877 |
+
console.log('Canvas container too small, retrying in 100ms...');
|
| 878 |
+
setTimeout(initHandwritingBoard, 100);
|
| 879 |
+
return;
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
// 2. 清空並建立原生 Canvas
|
| 883 |
+
wrapper.innerHTML = '';
|
| 884 |
+
const canvas = document.createElement('canvas');
|
| 885 |
+
canvas.id = 'handwriting-canvas';
|
| 886 |
+
canvas.style.width = '100%';
|
| 887 |
+
canvas.style.height = '250px';
|
| 888 |
+
canvas.style.backgroundColor = '#ffffff';
|
| 889 |
+
canvas.style.border = '2px dashed #cbd5e1';
|
| 890 |
+
canvas.style.borderRadius = '0.75rem';
|
| 891 |
+
canvas.style.cursor = 'crosshair';
|
| 892 |
+
canvas.style.touchAction = 'none'; // 防止觸控時捲動
|
| 893 |
+
canvas.style.display = 'block';
|
| 894 |
+
|
| 895 |
+
// 設定 HTML 屬性的寬高 (解析度),必須與 CSS 顯示大小匹配
|
| 896 |
+
canvas.width = rect.width;
|
| 897 |
+
canvas.height = 250;
|
| 898 |
+
|
| 899 |
+
wrapper.appendChild(canvas);
|
| 900 |
+
|
| 901 |
+
// 3. 設定 Context
|
| 902 |
+
canvasCtx = canvas.getContext('2d');
|
| 903 |
+
canvasCtx.lineWidth = 5;
|
| 904 |
+
canvasCtx.lineCap = 'round';
|
| 905 |
+
canvasCtx.lineJoin = 'round';
|
| 906 |
+
canvasCtx.strokeStyle = '#000000';
|
| 907 |
+
|
| 908 |
+
// 4. 重置筆跡資料
|
| 909 |
+
currentStrokes = [];
|
| 910 |
+
isDrawing = false;
|
| 911 |
+
|
| 912 |
+
// 5. 綁定事件監聽器
|
| 913 |
+
// 滑鼠
|
| 914 |
+
canvas.addEventListener('mousedown', startStroke);
|
| 915 |
+
canvas.addEventListener('mousemove', moveStroke);
|
| 916 |
+
canvas.addEventListener('mouseup', endStroke);
|
| 917 |
+
canvas.addEventListener('mouseleave', endStroke);
|
| 918 |
+
// 觸控
|
| 919 |
+
canvas.addEventListener('touchstart', startStroke, {passive: false});
|
| 920 |
+
canvas.addEventListener('touchmove', moveStroke, {passive: false});
|
| 921 |
+
canvas.addEventListener('touchend', endStroke);
|
| 922 |
+
|
| 923 |
+
console.log('Native Handwriting board initialized with width:', rect.width);
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
// [新增] 筆畫開始
|
| 927 |
+
function startStroke(e) {
|
| 928 |
+
if (e.type === 'touchstart') e.preventDefault(); // 防止滾動
|
| 929 |
+
isDrawing = true;
|
| 930 |
+
const pos = getPos(e);
|
| 931 |
|
| 932 |
+
// 初始化新的一筆:[x陣列, y陣列, t陣列]
|
| 933 |
+
// 這裡我們先用物件暫存,endStroke 時再轉成陣列推入 currentStrokes
|
| 934 |
+
currentStroke = { x: [pos.x], y: [pos.y], t: [Date.now()] };
|
| 935 |
|
| 936 |
+
canvasCtx.beginPath();
|
| 937 |
+
canvasCtx.moveTo(pos.x, pos.y);
|
| 938 |
+
|
| 939 |
+
// 視覺回饋
|
| 940 |
+
document.getElementById('handwriting-canvas').classList.add('canvas-active');
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
// [新增] 筆畫移動
|
| 944 |
+
function moveStroke(e) {
|
| 945 |
+
if (!isDrawing) return;
|
| 946 |
+
if (e.type === 'touchmove') e.preventDefault(); // 防止滾動
|
| 947 |
+
|
| 948 |
+
const pos = getPos(e);
|
| 949 |
+
currentStroke.x.push(pos.x);
|
| 950 |
+
currentStroke.y.push(pos.y);
|
| 951 |
+
currentStroke.t.push(Date.now());
|
| 952 |
+
|
| 953 |
+
canvasCtx.lineTo(pos.x, pos.y);
|
| 954 |
+
canvasCtx.stroke();
|
| 955 |
+
}
|
| 956 |
|
| 957 |
+
// [新增] 筆畫結束
|
| 958 |
+
function endStroke(e) {
|
| 959 |
+
if (!isDrawing) return;
|
| 960 |
+
isDrawing = false;
|
| 961 |
+
canvasCtx.closePath();
|
| 962 |
+
|
| 963 |
+
// 將暫存的筆畫轉為 Google API 格式並存入總筆跡
|
| 964 |
+
// Google 格式: [ [x1, x2...], [y1, y2...], [t1, t2...] ]
|
| 965 |
+
if (currentStroke.x.length > 0) {
|
| 966 |
+
currentStrokes.push([
|
| 967 |
+
currentStroke.x,
|
| 968 |
+
currentStroke.y,
|
| 969 |
+
currentStroke.t
|
| 970 |
+
]);
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
document.getElementById('handwriting-canvas').classList.remove('canvas-active');
|
| 974 |
+
}
|
| 975 |
|
| 976 |
+
// [新增] 取得座標 (相對於 Canvas)
|
| 977 |
+
function getPos(e) {
|
| 978 |
+
const canvas = document.getElementById('handwriting-canvas');
|
| 979 |
+
const rect = canvas.getBoundingClientRect();
|
| 980 |
+
let clientX, clientY;
|
| 981 |
+
|
| 982 |
+
if (e.touches && e.touches.length > 0) {
|
| 983 |
+
clientX = e.touches[0].clientX;
|
| 984 |
+
clientY = e.touches[0].clientY;
|
| 985 |
+
} else {
|
| 986 |
+
clientX = e.clientX;
|
| 987 |
+
clientY = e.clientY;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
return {
|
| 991 |
+
x: clientX - rect.left,
|
| 992 |
+
y: clientY - rect.top
|
| 993 |
};
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
// [新增] 清空畫布與資料
|
| 997 |
+
function clearCanvas() {
|
| 998 |
+
const canvas = document.getElementById('handwriting-canvas');
|
| 999 |
+
if(!canvas || !canvasCtx) return;
|
| 1000 |
+
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
| 1001 |
+
currentStrokes = [];
|
| 1002 |
+
answerInput.value = '';
|
| 1003 |
+
candidateBar.innerHTML = '';
|
| 1004 |
+
candidateBar.classList.add('hidden');
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
// [新增] 復原上一筆
|
| 1008 |
+
function undoStroke() {
|
| 1009 |
+
if (currentStrokes.length === 0) return;
|
| 1010 |
+
currentStrokes.pop();
|
| 1011 |
+
redrawCanvas();
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
// [新增] 重繪畫布 (用於 Undo)
|
| 1015 |
+
function redrawCanvas() {
|
| 1016 |
+
const canvas = document.getElementById('handwriting-canvas');
|
| 1017 |
+
if(!canvas || !canvasCtx) return;
|
| 1018 |
+
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
| 1019 |
|
| 1020 |
+
canvasCtx.beginPath();
|
| 1021 |
+
currentStrokes.forEach(stroke => {
|
| 1022 |
+
// stroke is [[x...], [y...], [t...]]
|
| 1023 |
+
const xs = stroke[0];
|
| 1024 |
+
const ys = stroke[1];
|
| 1025 |
+
if (xs.length > 0) {
|
| 1026 |
+
canvasCtx.moveTo(xs[0], ys[0]);
|
| 1027 |
+
for (let i = 1; i < xs.length; i++) {
|
| 1028 |
+
canvasCtx.lineTo(xs[i], ys[i]);
|
|
|
|
| 1029 |
}
|
| 1030 |
+
}
|
| 1031 |
+
});
|
| 1032 |
+
canvasCtx.stroke();
|
| 1033 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
|
| 1035 |
+
// [修正] 辨識手寫 (呼叫 Google API) - 修正 INVALID_INPUT_METHOD_NAME
|
| 1036 |
+
async function recognizeHandwriting() {
|
| 1037 |
+
if (currentStrokes.length === 0) {
|
| 1038 |
+
showToast("請先手寫內容!");
|
| 1039 |
+
return;
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
hwRecognizeBtn.disabled = true;
|
| 1043 |
+
hwRecognizeBtn.textContent = "辨識中...";
|
| 1044 |
+
|
| 1045 |
+
const canvas = document.getElementById('handwriting-canvas');
|
| 1046 |
+
const width = canvas.width;
|
| 1047 |
+
const height = canvas.height;
|
| 1048 |
+
|
| 1049 |
+
// [修正] 移除 language 欄位,避免參數衝突
|
| 1050 |
+
const payload = {
|
| 1051 |
+
"options": "enable_pre_space",
|
| 1052 |
+
"requests": [{
|
| 1053 |
+
"writing_guide": {
|
| 1054 |
+
"writing_area_width": width,
|
| 1055 |
+
"writing_area_height": height
|
| 1056 |
+
},
|
| 1057 |
+
"ink": currentStrokes
|
| 1058 |
+
}]
|
| 1059 |
+
};
|
| 1060 |
|
| 1061 |
+
try {
|
| 1062 |
+
// [修正] 改用最通用的 www.google.com 端點,並將 app 參數改為 translate
|
| 1063 |
+
const response = await fetch('https://www.google.com/inputtools/request?itc=en-t-i0-handwriting&app=translate', {
|
| 1064 |
+
method: 'POST',
|
| 1065 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1066 |
+
body: JSON.stringify(payload)
|
| 1067 |
});
|
| 1068 |
+
|
| 1069 |
+
const result = await response.json();
|
| 1070 |
+
|
| 1071 |
+
// 解析回傳資料: ["SUCCESS", [[["candidate1", ...], ...]]]
|
| 1072 |
+
if (result[0] === 'SUCCESS' && result[1] && result[1][0] && result[1][0][1]) {
|
| 1073 |
+
const candidates = result[1][0][1];
|
| 1074 |
+
|
| 1075 |
+
// 1. 填入第一個候選字
|
| 1076 |
+
answerInput.value = candidates[0];
|
| 1077 |
+
|
| 1078 |
+
// 2. 顯示候選字列
|
| 1079 |
+
showCandidates(candidates);
|
| 1080 |
+
|
| 1081 |
+
// 視覺回饋
|
| 1082 |
+
answerInput.classList.add('border-green-500', 'bg-green-50');
|
| 1083 |
+
setTimeout(() => answerInput.classList.remove('border-green-500', 'bg-green-50'), 500);
|
| 1084 |
+
} else {
|
| 1085 |
+
console.warn("API 回傳格式不如預期", result);
|
| 1086 |
+
if(result[0] === 'INVALID_INPUT_METHOD_NAME') {
|
| 1087 |
+
showToast("API 錯誤:輸入法名稱無效,請確認網路環境或稍後再試。");
|
| 1088 |
+
}
|
| 1089 |
+
}
|
| 1090 |
+
} catch (error) {
|
| 1091 |
+
console.error("手寫辨識失敗:", error);
|
| 1092 |
+
showToast("辨識失敗,請檢查網路連線。");
|
| 1093 |
+
} finally {
|
| 1094 |
+
hwRecognizeBtn.disabled = false;
|
| 1095 |
+
hwRecognizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /></svg> 辨識輸入`;
|
| 1096 |
}
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
// [新增] 顯示候選字
|
| 1100 |
+
function showCandidates(candidates) {
|
| 1101 |
+
candidateBar.innerHTML = '';
|
| 1102 |
+
candidateBar.classList.remove('hidden');
|
| 1103 |
|
| 1104 |
+
candidates.forEach(text => {
|
| 1105 |
+
const btn = document.createElement('button');
|
| 1106 |
+
btn.textContent = text;
|
| 1107 |
+
btn.className = 'candidate-btn';
|
| 1108 |
+
btn.onclick = () => {
|
| 1109 |
+
answerInput.value = text;
|
| 1110 |
+
answerInput.focus();
|
| 1111 |
+
};
|
| 1112 |
+
candidateBar.appendChild(btn);
|
| 1113 |
});
|
| 1114 |
}
|
| 1115 |
|
| 1116 |
+
// 綁定手寫按鈕事件
|
| 1117 |
+
if (hwClearBtn) hwClearBtn.addEventListener('click', clearCanvas);
|
| 1118 |
+
if (hwUndoBtn) hwUndoBtn.addEventListener('click', undoStroke);
|
| 1119 |
+
if (hwRecognizeBtn) hwRecognizeBtn.addEventListener('click', recognizeHandwriting);
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
// [新增] 更新輸入介面 (控制手寫板顯示與輸入法)
|
|
|
|
| 1123 |
function updateInputInterface(expectedLang) {
|
| 1124 |
const hwContainer = document.getElementById('handwriting-container');
|
| 1125 |
|
| 1126 |
+
// [修改] 為了方便在 PC 上除錯,暫時移除了 effectiveVersion === 'mobile' 的檢查
|
| 1127 |
+
// 現在只要是輸入英文模式 (expectedLang === 'en'),都會顯示手寫板
|
| 1128 |
+
const shouldUseHandwriting = (expectedLang === 'en');
|
| 1129 |
|
| 1130 |
if (shouldUseHandwriting) {
|
| 1131 |
+
// --- 啟用手寫模式 ---
|
| 1132 |
+
// 1. 顯示
|
| 1133 |
hwContainer.classList.remove('hidden');
|
| 1134 |
hwContainer.classList.add('flex');
|
| 1135 |
|
| 1136 |
+
// 2. 初始化 (延遲以確保 flex 渲染完成)
|
| 1137 |
+
// [關鍵] 延遲時間設為 400ms,配合 CSS transition
|
| 1138 |
+
setTimeout(() => initHandwritingBoard(), 400);
|
| 1139 |
|
| 1140 |
+
// 3. 鎖定輸入框
|
| 1141 |
answerInput.readOnly = true;
|
| 1142 |
answerInput.placeholder = "請在下方手寫英文...";
|
| 1143 |
answerInput.classList.add('handwriting-mode');
|
| 1144 |
|
| 1145 |
+
// 4. 嘗試讓輸入框失去焦點,避免鍵盤彈出
|
| 1146 |
+
if (document.activeElement === answerInput) {
|
| 1147 |
+
answerInput.blur();
|
| 1148 |
}
|
| 1149 |
|
| 1150 |
} else {
|
| 1151 |
+
// --- 啟用鍵盤模式 (PC 或 輸入中文) ---
|
|
|
|
| 1152 |
hwContainer.classList.add('hidden');
|
| 1153 |
hwContainer.classList.remove('flex');
|
| 1154 |
|
|
|
|
| 1155 |
answerInput.readOnly = false;
|
| 1156 |
answerInput.placeholder = (expectedLang === 'zh') ? "輸入中文答案 (按 Enter 提交)" : "輸入英文答案 (按 Enter 提交)";
|
| 1157 |
answerInput.classList.remove('handwriting-mode');
|
|
|
|
| 1466 |
if (mode === 'sentence-cloze') {
|
| 1467 |
wordsForCurrentMode = wordPool.filter(w => w.sentence && w.sentence.en && w.sentence.zh && w.sentence.answer);
|
| 1468 |
if (wordsForCurrentMode.length === 0) {
|
| 1469 |
+
showToast("題庫中沒有附帶完整例句(包含克漏字答案)的單字可供此模式使用。");
|
| 1470 |
return;
|
| 1471 |
}
|
| 1472 |
} else if (mode === 'review') {
|
|
|
|
| 1476 |
if (!isNaN(randomCount) && randomCount > 0 && randomCount <= wordPool.length) {
|
| 1477 |
wordsForCurrentMode = shuffleArray([...wordPool]).slice(0, randomCount);
|
| 1478 |
} else {
|
| 1479 |
+
showToast('請輸入有效的隨機題數。題數不能為零或超過所選範圍的單字總數。');
|
| 1480 |
return;
|
| 1481 |
}
|
| 1482 |
} else {
|
|
|
|
| 1484 |
}
|
| 1485 |
|
| 1486 |
if (wordsForCurrentMode.length === 0) {
|
| 1487 |
+
showToast("選定的範圍內沒有單字,請重新選擇或新增。");
|
| 1488 |
return;
|
| 1489 |
}
|
| 1490 |
|
|
|
|
| 1492 |
const hardWords = words.filter(w => w.incorrectCount > 0)
|
| 1493 |
.sort((a, b) => b.incorrectCount - a.incorrectCount).slice(0, 10);
|
| 1494 |
if (hardWords.length === 0) {
|
| 1495 |
+
showToast('太棒了!目前沒有需要特別複習的困難單字。'); return;
|
| 1496 |
}
|
| 1497 |
wordsForCurrentMode = hardWords;
|
| 1498 |
quizQueue = shuffleArray([...wordsForCurrentMode]);
|
|
|
|
| 1585 |
feedbackDisplay.classList.add('text-green-600');
|
| 1586 |
triggerConfetti();
|
| 1587 |
|
| 1588 |
+
// [修正] 使用新的清空函式
|
| 1589 |
+
clearCanvas();
|
|
|
|
| 1590 |
|
| 1591 |
if (!flashcardContainer.classList.contains('flipped')) {
|
| 1592 |
flashcardContainer.classList.add('flipped');
|
|
|
|
| 1687 |
const wrongCard = quizQueue.shift();
|
| 1688 |
const reinsertIndex = Math.min(quizQueue.length, 3);
|
| 1689 |
quizQueue.splice(reinsertIndex, 0, wrongCard);
|
| 1690 |
+
// [修正] 清空手寫板
|
| 1691 |
+
clearCanvas();
|
|
|
|
|
|
|
| 1692 |
displayCard();
|
| 1693 |
});
|
| 1694 |
|
|
|
|
| 1956 |
async function callGeminiToParseText(text) {
|
| 1957 |
const apiKey = apiKeyInput.value.trim();
|
| 1958 |
if (!apiKey) {
|
| 1959 |
+
showToast('錯誤:請先輸入您的 Gemini API 金鑰並儲存。');
|
| 1960 |
parsePdfBtn.disabled = false;
|
| 1961 |
aiProgressContainer.classList.add('hidden');
|
| 1962 |
return;
|
|
|
|
| 2260 |
saveTitleBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700');
|
| 2261 |
}, 2000);
|
| 2262 |
} else {
|
| 2263 |
+
showToast('冊次和課次不能為空!');
|
| 2264 |
}
|
| 2265 |
});
|
| 2266 |
|
|
|
|
| 2288 |
const { collection, addDoc } = window.firebaseFirestore;
|
| 2289 |
|
| 2290 |
if (!db) {
|
| 2291 |
+
showToast("Firebase 初始化失敗,無法分享。");
|
| 2292 |
generateLinkBtn.textContent = '產生分享連結';
|
| 2293 |
generateLinkBtn.disabled = false;
|
| 2294 |
return;
|
|
|
|
| 2319 |
|
| 2320 |
} catch (error) {
|
| 2321 |
console.error("分享至 Firebase 失敗:", error);
|
| 2322 |
+
showToast("分享失敗,請檢查您的網路連線或 Firebase 設定。");
|
| 2323 |
} finally {
|
| 2324 |
generateLinkBtn.textContent = '重新產生';
|
| 2325 |
generateLinkBtn.disabled = false;
|
|
|
|
| 2413 |
}, 2000);
|
| 2414 |
} else {
|
| 2415 |
localStorage.removeItem('geminiApiKey');
|
| 2416 |
+
showToast('API 金鑰已清除。');
|
| 2417 |
}
|
| 2418 |
});
|
| 2419 |
|
|
|
|
| 2513 |
loadedFromUrl = true;
|
| 2514 |
}
|
| 2515 |
} else {
|
| 2516 |
+
showToast("找不到分享的單字庫,請確認連結是否正確。");
|
| 2517 |
}
|
| 2518 |
} catch (error) {
|
| 2519 |
console.error("從 Firebase 讀取資料失敗:", error);
|
| 2520 |
+
showToast("讀取分享資料時發生錯誤。");
|
| 2521 |
}
|
| 2522 |
}
|
| 2523 |
}
|