Lashtw commited on
Commit
6a548e9
·
verified ·
1 Parent(s): 85541dc

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +332 -164
index.html CHANGED
@@ -135,8 +135,17 @@
135
  }
136
 
137
  /* 觸摸手寫板時的視覺回饋類別 */
138
- .canvas-active {
 
 
 
 
 
 
139
  border-color: #6366f1 !important; /* Indigo-500 */
 
 
 
140
  background-color: #f0fdf4 !important; /* Green-50 */
141
  }
142
 
@@ -416,7 +425,8 @@
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
 
@@ -663,12 +673,12 @@
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
  // [新增] TrOCR 相關變數
673
  let ocrPipeline = null; // 存放載入好的模型
674
  let isModelLoading = false;
@@ -851,6 +861,8 @@
851
  let currentSpeedQuestionType = '';
852
  let quizStartTime = 0;
853
  let parsedWordsFromAI = [];
 
 
854
 
855
 
856
  const modeDetails = {
@@ -870,121 +882,207 @@
870
  }, 3000);
871
  }
872
 
873
- // [修改] 核心重寫:初始化手寫板功能 (使用原生 Canvas API)
874
- function initHandwritingBoard() {
875
  const wrapper = document.getElementById('canvas-wrapper');
876
  if (!wrapper) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
 
878
- // 1. 確保 wrapper 可見且有寬度
879
- const rect = wrapper.getBoundingClientRect();
880
- if (rect.width < 50) {
881
- console.log('Canvas container too small, retrying in 100ms...');
882
- setTimeout(initHandwritingBoard, 100);
883
- return;
884
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
 
886
- // 2. 清空並建立原生 Canvas
887
- wrapper.innerHTML = '';
888
- const canvas = document.createElement('canvas');
889
- canvas.id = 'handwriting-canvas';
890
- canvas.style.width = '100%';
891
- canvas.style.height = '250px';
892
- canvas.style.backgroundColor = '#ffffff';
893
- canvas.style.border = '2px dashed #cbd5e1';
894
- canvas.style.borderRadius = '0.75rem';
895
- canvas.style.cursor = 'crosshair';
896
- canvas.style.touchAction = 'none'; // 防止觸控時捲動
897
- canvas.style.display = 'block';
898
-
899
- // 設定 HTML 屬性的寬高 (解析度),必須與 CSS 顯示大小匹配
900
- canvas.width = rect.width;
901
- canvas.height = 250;
902
-
903
- wrapper.appendChild(canvas);
904
 
905
- // 3. 設定 Context
906
- canvasCtx = canvas.getContext('2d', { willReadFrequently: true }); // [優化] 加入讀取優化提示
907
-
908
- // [修改] 為了 TrOCR 模型,強制將畫布背景填滿白色像素 (透明背景會導致辨識失敗)
909
- canvasCtx.fillStyle = '#ffffff';
910
- canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
911
 
912
- canvasCtx.lineWidth = 10; // [修改] 增加線條寬度以利 OCR 辨識
913
- canvasCtx.lineCap = 'round';
914
- canvasCtx.lineJoin = 'round';
915
- canvasCtx.strokeStyle = '#000000';
 
 
 
 
 
 
 
 
 
 
916
 
917
- // 4. 重置筆跡資料
918
- currentStrokes = [];
919
- isDrawing = false;
 
 
 
 
 
 
 
 
 
 
 
920
 
921
- // 5. 綁定事件監聽器
922
- // 滑鼠
923
- canvas.addEventListener('mousedown', startStroke);
924
- canvas.addEventListener('mousemove', moveStroke);
925
- canvas.addEventListener('mouseup', endStroke);
926
- canvas.addEventListener('mouseleave', endStroke);
927
- // 觸控
928
- canvas.addEventListener('touchstart', startStroke, {passive: false});
929
- canvas.addEventListener('touchmove', moveStroke, {passive: false});
930
- canvas.addEventListener('touchend', endStroke);
931
-
932
- console.log('Native Handwriting board initialized with width:', rect.width);
933
  }
934
 
935
- // [新增] 筆畫開始
936
- function startStroke(e) {
937
- if (e.type === 'touchstart') e.preventDefault(); // 防止滾動
938
  isDrawing = true;
939
- const pos = getPos(e);
940
-
941
- // 初始化新的一筆:[x陣列, y陣列, t陣列]
942
- // 這裡我們先用物件暫存,endStroke 時再轉成陣列推入 currentStrokes
943
- currentStroke = { x: [pos.x], y: [pos.y], t: [Date.now()] };
944
 
945
- canvasCtx.beginPath();
946
- canvasCtx.moveTo(pos.x, pos.y);
947
 
948
- // 視覺回饋
949
- document.getElementById('handwriting-canvas').classList.add('canvas-active');
950
  }
951
 
952
- // [新增] 筆畫移動
953
- function moveStroke(e) {
954
  if (!isDrawing) return;
955
- if (e.type === 'touchmove') e.preventDefault(); // 防止滾動
956
-
957
- const pos = getPos(e);
958
- currentStroke.x.push(pos.x);
959
- currentStroke.y.push(pos.y);
960
- currentStroke.t.push(Date.now());
961
 
962
- canvasCtx.lineTo(pos.x, pos.y);
963
- canvasCtx.stroke();
 
 
 
 
 
964
  }
965
 
966
- // [新增] 筆畫結束
967
- function endStroke(e) {
968
  if (!isDrawing) return;
969
  isDrawing = false;
970
- canvasCtx.closePath();
 
 
 
971
 
972
- // 將暫存的筆畫轉為 Google API 格式並存入總筆跡
973
- // Google 格式: [ [x1, x2...], [y1, y2...], [t1, t2...] ]
974
- if (currentStroke.x.length > 0) {
975
- currentStrokes.push([
976
- currentStroke.x,
977
- currentStroke.y,
978
- currentStroke.t
979
  ]);
980
  }
981
 
982
- document.getElementById('handwriting-canvas').classList.remove('canvas-active');
983
  }
984
 
985
- // [新增] 取得座標 (相對於 Canvas)
986
- function getPos(e) {
987
- const canvas = document.getElementById('handwriting-canvas');
988
  const rect = canvas.getBoundingClientRect();
989
  let clientX, clientY;
990
 
@@ -1002,50 +1100,50 @@
1002
  };
1003
  }
1004
 
1005
- // [新增] 清空畫布與資料
1006
  function clearCanvas() {
1007
- const canvas = document.getElementById('handwriting-canvas');
1008
- if(!canvas || !canvasCtx) return;
1009
 
1010
- // [修改] 使用 fillRect 填滿白色,取代 clearRect (避免透明背景)
1011
- canvasCtx.fillStyle = '#ffffff';
1012
- canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
1013
 
1014
- currentStrokes = [];
1015
- answerInput.value = '';
 
 
 
1016
  candidateBar.innerHTML = '';
1017
  candidateBar.classList.add('hidden');
1018
  }
1019
 
1020
- // [新增] 復原上一筆
1021
  function undoStroke() {
1022
- if (currentStrokes.length === 0) return;
1023
- currentStrokes.pop();
1024
- redrawCanvas();
 
1025
  }
1026
 
1027
- // [新增] 重繪畫布 (用於 Undo)
1028
- function redrawCanvas() {
1029
- const canvas = document.getElementById('handwriting-canvas');
1030
- if(!canvas || !canvasCtx) return;
1031
 
1032
- // [修改] 重繪前先填滿白色背景
1033
- canvasCtx.fillStyle = '#ffffff';
1034
- canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
1035
 
1036
- canvasCtx.beginPath();
1037
- currentStrokes.forEach(stroke => {
1038
- // stroke is [[x...], [y...], [t...]]
1039
  const xs = stroke[0];
1040
  const ys = stroke[1];
1041
  if (xs.length > 0) {
1042
- canvasCtx.moveTo(xs[0], ys[0]);
1043
  for (let i = 1; i < xs.length; i++) {
1044
- canvasCtx.lineTo(xs[i], ys[i]);
1045
  }
1046
  }
1047
  });
1048
- canvasCtx.stroke();
1049
  }
1050
 
1051
  // [新增] 載入 TrOCR AI 模型 (使用 dynamic import)
@@ -1086,8 +1184,9 @@
1086
 
1087
  // [修正] 辨識手寫 (改用 TrOCR)
1088
  async function recognizeHandwriting() {
1089
- // 1. 檢查是否有筆跡
1090
- if (currentStrokes.length === 0) {
 
1091
  showToast("請先手寫內容!");
1092
  return;
1093
  }
@@ -1102,26 +1201,50 @@
1102
  hwRecognizeBtn.textContent = "AI 辨識中...";
1103
 
1104
  try {
1105
- const canvas = document.getElementById('handwriting-canvas');
1106
- // Canvas 轉為 Data URL
1107
- const imageData = canvas.toDataURL("image/png");
 
 
 
 
 
 
 
 
 
 
 
 
1108
 
1109
- // 呼叫 pipeline 進行辨識
1110
- const output = await ocrPipeline(imageData);
1111
- // 輸出格式通常是 [{ generated_text: "hello" }]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
 
1113
- const text = output[0]?.generated_text?.trim();
1114
 
1115
- if (text) {
1116
- // TrOCR 可能會回傳句點,如果是單字練習可以移除
1117
- const cleanedText = text.replace(/\.$/, '');
1118
-
1119
  answerInput.value = cleanedText;
1120
  answerInput.classList.add('border-green-500', 'bg-green-50');
1121
  setTimeout(() => answerInput.classList.remove('border-green-500', 'bg-green-50'), 500);
1122
  showToast("辨識結果:" + cleanedText);
1123
-
1124
- // 也可以把原文當作候選字 (TrOCR 通常只回傳最可能的結果,不像 API 有多個候選)
1125
  showCandidates([cleanedText]);
1126
  } else {
1127
  showToast("未能辨識出文字");
@@ -1160,25 +1283,51 @@
1160
 
1161
 
1162
  // [新增] 更新輸入介面 (控制手寫板顯示與輸入法)
1163
- function updateInputInterface(expectedLang) {
 
1164
  const hwContainer = document.getElementById('handwriting-container');
1165
 
1166
  // [修改] 為了方便在 PC 上除錯,暫時移除了 effectiveVersion === 'mobile' 的檢查
1167
  // 現在只要是輸入英文模式 (expectedLang === 'en'),都會顯示手寫板
1168
- const shouldUseHandwriting = (expectedLang === 'en');
1169
 
1170
  if (shouldUseHandwriting) {
1171
  // --- 啟用手寫模式 ---
1172
- // 1. 顯示
1173
  hwContainer.classList.remove('hidden');
1174
  hwContainer.classList.add('flex');
1175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  // 2. 初始化 (延遲以確保 flex 渲染完成)
1177
- // [關鍵] 延遲時間設為 400ms,配合 CSS transition
1178
- setTimeout(() => initHandwritingBoard(), 400);
1179
 
1180
  // 3. 觸發模型預載 (Lazy Load)
1181
- // 當使用者切換到英文輸入模式時,就開始在背景下載模型
1182
  if (!ocrPipeline && !isModelLoading) {
1183
  initOCR();
1184
  }
@@ -1187,11 +1336,7 @@
1187
  answerInput.readOnly = true;
1188
  answerInput.placeholder = "請在下方手寫英文...";
1189
  answerInput.classList.add('handwriting-mode');
1190
-
1191
- // 5. 嘗試讓輸入框失去焦點,避免鍵盤彈出
1192
- if (document.activeElement === answerInput) {
1193
- answerInput.blur();
1194
- }
1195
 
1196
  } else {
1197
  // --- 啟用鍵盤模式 (PC 或 輸入中文) ---
@@ -1199,11 +1344,11 @@
1199
  hwContainer.classList.remove('flex');
1200
 
1201
  answerInput.readOnly = false;
1202
- answerInput.placeholder = (expectedLang === 'zh') ? "輸入中文答案 (按 Enter 提交)" : "輸入英文答案 (按 Enter 提交)";
1203
  answerInput.classList.remove('handwriting-mode');
1204
  }
1205
 
1206
- console.log(`輸入模式: ${shouldUseHandwriting ? '手寫 (Handwriting)' : '鍵盤 (Keyboard)'}, 語言: ${expectedLang}`);
1207
  }
1208
 
1209
  // --- 視圖管理 ---
@@ -1398,6 +1543,8 @@
1398
 
1399
  let frontText, backText;
1400
  let targetLanguage = 'en'; // 預設輸入英文
 
 
1401
 
1402
  if (isSpeed) {
1403
  // 極速挑戰
@@ -1406,6 +1553,7 @@
1406
  frontText = card.chinese;
1407
  backText = card.english;
1408
  targetLanguage = 'en';
 
1409
  break;
1410
  case 'en-zh':
1411
  frontText = card.english;
@@ -1419,6 +1567,7 @@
1419
  [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
1420
  speakWord(card);
1421
  targetLanguage = 'en';
 
1422
  break;
1423
  }
1424
  } else {
@@ -1433,10 +1582,13 @@
1433
  // 複習模式下:沒翻面(看英文)=輸入中文,翻面(看中文)=輸入英文
1434
  const isFlipped = flashcardContainer.classList.contains('flipped');
1435
  targetLanguage = isFlipped ? 'en' : 'zh';
 
 
1436
  break;
1437
  case 'zh-en': case 'hard':
1438
  frontText = card.chinese; backText = card.english;
1439
  targetLanguage = 'en';
 
1440
  break;
1441
  case 'en-zh':
1442
  frontText = card.english; backText = card.chinese;
@@ -1448,6 +1600,7 @@
1448
  [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
1449
  speakWord(card);
1450
  targetLanguage = 'en';
 
1451
  break;
1452
  case 'sentence-cloze':
1453
  clozeQuestionContainer.classList.remove('hidden');
@@ -1457,12 +1610,13 @@
1457
  toggleTranslationBtn.textContent = '顯示翻譯';
1458
  backText = card.sentence.answer || card.english;
1459
  targetLanguage = 'en';
 
1460
  break;
1461
  }
1462
  }
1463
 
1464
- // [新增] 根據目標語言決定是否顯示手寫板
1465
- updateInputInterface(targetLanguage);
1466
 
1467
  if (frontText) {
1468
  frontDisplay.classList.remove('hidden');
@@ -1768,6 +1922,11 @@
1768
  endSpeedChallenge();
1769
  }
1770
  };
 
 
 
 
 
1771
 
1772
  const endSpeedChallenge = () => {
1773
  clearInterval(timerInterval);
@@ -2215,7 +2374,16 @@
2215
 
2216
  // [智慧輸入切換] 複習模式下:翻面後看到中文->要輸入英文
2217
  const targetLanguage = isFlipped ? 'en' : 'zh';
2218
- updateInputInterface(targetLanguage);
 
 
 
 
 
 
 
 
 
2219
 
2220
  feedbackDisplay.textContent = '';
2221
  setTimeout(() => answerInput.focus(), 100);
@@ -2579,6 +2747,19 @@
2579
  } else {
2580
  // 【修改】使用新的預設單字,展示文法感知功能
2581
  const defaultWords = [
 
 
 
 
 
 
 
 
 
 
 
 
 
2582
  {
2583
  english: 'watch (v.)',
2584
  chinese: '觀看',
@@ -2588,21 +2769,8 @@
2588
  answer: 'watches'
2589
  }
2590
  },
2591
- {
2592
- english: 'jog (v.)',
2593
- chinese: '慢跑',
2594
- sentence: {
2595
- en: 'She is ___ in the park now.',
2596
- zh: '她現在正在公園慢跑。',
2597
- answer: 'jogging'
2598
- }
2599
- },
2600
  { english: 'study (v.)', chinese: '研讀' },
2601
  { english: 'last (adj.)', chinese: '前一個的' },
2602
- { english: 'death (n.)', chinese: '死亡' },
2603
- { english: 'a few (adj.)', chinese: '一些' },
2604
- { english: 'ago (adv.)', chinese: '以前' },
2605
- { english: 'parents (n.)', chinese: '父母親' },
2606
  ];
2607
  words = defaultWords.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 }));
2608
  saveWordsToStorage();
 
135
  }
136
 
137
  /* 觸摸手寫板時的視覺回饋類別 */
138
+ .canvas-container {
139
+ border: 2px dashed #cbd5e1;
140
+ border-radius: 0.75rem;
141
+ overflow: hidden;
142
+ transition: all 0.2s;
143
+ }
144
+ .canvas-container.active-canvas {
145
  border-color: #6366f1 !important; /* Indigo-500 */
146
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
147
+ }
148
+ .canvas-active-interaction {
149
  background-color: #f0fdf4 !important; /* Green-50 */
150
  }
151
 
 
425
 
426
  <div id="handwriting-container" class="hidden flex-col gap-2">
427
  <!-- 使用 wrapper 包裹 canvas,並強制設定固定高度,防止高度塌陷 -->
428
+ <!-- [修改] 改為 flex 容器以容納多個畫布 -->
429
+ <div id="canvas-wrapper" class="relative w-full min-h-[250px] flex flex-wrap gap-2 justify-center items-center">
430
  <!-- Canvas 將會由 JS 動態生成插入 -->
431
  </div>
432
 
 
673
  // [第四步] JavaScript 邏輯 (Script)
674
  let effectiveVersion = 'pc'; // 預設為 PC 版
675
 
676
+ // [修改] 移除單一畫布變數,改用陣列管理多個畫布實例
677
  let isDrawing = false;
678
+ // hwInstances 存放物件: { id, element, ctx, strokes: [], history: [] }
679
+ let hwInstances = [];
680
+ let activeHwIndex = 0; // 當前選取的畫布索引
681
+
682
  // [新增] TrOCR 相關變數
683
  let ocrPipeline = null; // 存放載入好的模型
684
  let isModelLoading = false;
 
861
  let currentSpeedQuestionType = '';
862
  let quizStartTime = 0;
863
  let parsedWordsFromAI = [];
864
+ // 用來存放多重手寫板的結構,例如 ['cheer', '...', 'on']
865
+ let currentHandwritingStructure = [];
866
 
867
 
868
  const modeDetails = {
 
882
  }, 3000);
883
  }
884
 
885
+ // [修改] 核心重寫:初始化多重手寫板功能
886
+ function initMultiHandwritingBoard(structure) {
887
  const wrapper = document.getElementById('canvas-wrapper');
888
  if (!wrapper) return;
889
+
890
+ // 重置狀態
891
+ wrapper.innerHTML = '';
892
+ hwInstances = [];
893
+ activeHwIndex = 0;
894
+
895
+ // 根據傳入的結構 (例如 ['cheer', '...', 'on'] 或 ['come', 'true']) 建立手寫板
896
+ structure.forEach((part, index) => {
897
+ if (part === '...') {
898
+ // 插入省略號文字
899
+ const ellipsis = document.createElement('div');
900
+ ellipsis.className = 'text-3xl font-bold text-gray-400 mx-1 select-none';
901
+ ellipsis.textContent = '...';
902
+ wrapper.appendChild(ellipsis);
903
+ // 省略號不佔用 hwInstances 索引
904
+ } else {
905
+ // 建立 Canvas 容器
906
+ const container = document.createElement('div');
907
+ container.className = 'canvas-container relative';
908
+ // 根據字數調整寬度,如果只有一個字就全寬,多個字則均分但有最小寬度
909
+ const wordCount = structure.filter(s => s !== '...').length;
910
+ if (wordCount === 1) {
911
+ container.style.width = '100%';
912
+ } else {
913
+ container.style.flex = '1 1 140px'; // Flex grow, shrink, basis
914
+ container.style.minWidth = '140px';
915
+ }
916
+ container.style.height = '250px';
917
+
918
+ // 建立 Canvas
919
+ const canvas = document.createElement('canvas');
920
+ canvas.dataset.index = hwInstances.length; // 暫存索引
921
+ canvas.style.width = '100%';
922
+ canvas.style.height = '100%';
923
+ canvas.style.cursor = 'crosshair';
924
+ canvas.style.touchAction = 'none';
925
+ container.appendChild(canvas);
926
+ wrapper.appendChild(container);
927
+
928
+ // 等待 DOM 渲染後設定 Canvas 實際��析度
929
+ // 使用 requestAnimationFrame 確保樣式套用後再讀取尺寸
930
+ requestAnimationFrame(() => {
931
+ const rect = container.getBoundingClientRect();
932
+ canvas.width = rect.width;
933
+ canvas.height = 250; // 固定高度
934
+
935
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
936
+ // 填滿白色背景
937
+ ctx.fillStyle = '#ffffff';
938
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
939
+ ctx.lineWidth = 10;
940
+ ctx.lineCap = 'round';
941
+ ctx.lineJoin = 'round';
942
+ ctx.strokeStyle = '#000000';
943
+
944
+ // 存入實例陣列
945
+ // 注意:由於 requestAnimationFrame 是非同步,這裡直接 push 可能會順序錯亂
946
+ // 但在這個簡單情境下,通常還好。嚴謹做法是預先建立物件佔位。
947
+ // 這裡我們直接修改已存在的物件引用 (稍微複雜,簡單點做)
948
+ // 修正:同步建立物件,非同步設定 Context
949
+ // 但為了簡化,我們在建立 element 時就同步 push 到 array
950
+ });
951
 
952
+ // 同步建立資料結構
953
+ const instance = {
954
+ id: hwInstances.length,
955
+ element: canvas,
956
+ container: container,
957
+ ctx: null, // 稍後初始化
958
+ strokes: [],
959
+ currentStroke: {x:[], y:[], t:[]} // 暫存筆畫
960
+ };
961
+ hwInstances.push(instance);
962
+
963
+ // 延遲初始化 Context (確保 width 正確)
964
+ setTimeout(() => {
965
+ const rect = container.getBoundingClientRect();
966
+ if (rect.width > 0) {
967
+ canvas.width = rect.width;
968
+ canvas.height = 250;
969
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
970
+ ctx.fillStyle = '#ffffff';
971
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
972
+ ctx.lineWidth = 10;
973
+ ctx.lineCap = 'round';
974
+ ctx.lineJoin = 'round';
975
+ ctx.strokeStyle = '#000000';
976
+ instance.ctx = ctx;
977
+
978
+ // 預設第一個畫布為啟用狀態
979
+ if (instance.id === 0) setActiveCanvas(0);
980
+ }
981
+ }, 50);
982
 
983
+ // 綁定事件
984
+ bindCanvasEvents(canvas, instance.id);
985
+ }
986
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
 
988
+ console.log('Multi-Handwriting board initialized with parts:', structure);
989
+ }
 
 
 
 
990
 
991
+ // 設定當前活躍的 Canvas
992
+ function setActiveCanvas(index) {
993
+ if (index < 0 || index >= hwInstances.length) return;
994
+ activeHwIndex = index;
995
+
996
+ // 更新視覺效果
997
+ hwInstances.forEach(inst => {
998
+ if (inst.id === index) {
999
+ inst.container.classList.add('active-canvas');
1000
+ } else {
1001
+ inst.container.classList.remove('active-canvas');
1002
+ }
1003
+ });
1004
+ }
1005
 
1006
+ // 綁定單一 Canvas 的事件
1007
+ function bindCanvasEvents(canvas, index) {
1008
+ const start = (e) => {
1009
+ if (e.type === 'touchstart') e.preventDefault();
1010
+ setActiveCanvas(index);
1011
+ startStroke(e, index);
1012
+ };
1013
+ const move = (e) => {
1014
+ if (e.type === 'touchmove') e.preventDefault();
1015
+ moveStroke(e, index);
1016
+ };
1017
+ const end = (e) => {
1018
+ endStroke(e, index);
1019
+ };
1020
 
1021
+ canvas.addEventListener('mousedown', start);
1022
+ canvas.addEventListener('mousemove', move);
1023
+ canvas.addEventListener('mouseup', end);
1024
+ canvas.addEventListener('mouseleave', end);
1025
+
1026
+ canvas.addEventListener('touchstart', start, {passive: false});
1027
+ canvas.addEventListener('touchmove', move, {passive: false});
1028
+ canvas.addEventListener('touchend', end);
1029
+
1030
+ // 點擊容器也能啟用
1031
+ canvas.parentElement.addEventListener('click', () => setActiveCanvas(index));
 
1032
  }
1033
 
1034
+ // [修改] 筆畫開始
1035
+ function startStroke(e, index) {
 
1036
  isDrawing = true;
1037
+ const inst = hwInstances[index];
1038
+ if (!inst || !inst.ctx) return;
1039
+
1040
+ const pos = getPos(e, inst.element);
1041
+ inst.currentStroke = { x: [pos.x], y: [pos.y], t: [Date.now()] };
1042
 
1043
+ inst.ctx.beginPath();
1044
+ inst.ctx.moveTo(pos.x, pos.y);
1045
 
1046
+ inst.container.classList.add('canvas-active-interaction');
 
1047
  }
1048
 
1049
+ // [修改] 筆畫移動
1050
+ function moveStroke(e, index) {
1051
  if (!isDrawing) return;
1052
+ const inst = hwInstances[index];
1053
+ if (!inst || !inst.ctx) return;
 
 
 
 
1054
 
1055
+ const pos = getPos(e, inst.element);
1056
+ inst.currentStroke.x.push(pos.x);
1057
+ inst.currentStroke.y.push(pos.y);
1058
+ inst.currentStroke.t.push(Date.now());
1059
+
1060
+ inst.ctx.lineTo(pos.x, pos.y);
1061
+ inst.ctx.stroke();
1062
  }
1063
 
1064
+ // [修改] 筆畫結束
1065
+ function endStroke(e, index) {
1066
  if (!isDrawing) return;
1067
  isDrawing = false;
1068
+ const inst = hwInstances[index];
1069
+ if (!inst || !inst.ctx) return;
1070
+
1071
+ inst.ctx.closePath();
1072
 
1073
+ if (inst.currentStroke.x.length > 0) {
1074
+ inst.strokes.push([
1075
+ inst.currentStroke.x,
1076
+ inst.currentStroke.y,
1077
+ inst.currentStroke.t
 
 
1078
  ]);
1079
  }
1080
 
1081
+ inst.container.classList.remove('canvas-active-interaction');
1082
  }
1083
 
1084
+ // [修改] 取得座標 (需傳入特定 canvas 元素)
1085
+ function getPos(e, canvas) {
 
1086
  const rect = canvas.getBoundingClientRect();
1087
  let clientX, clientY;
1088
 
 
1100
  };
1101
  }
1102
 
1103
+ // [修改] 清空當前畫布
1104
  function clearCanvas() {
1105
+ const inst = hwInstances[activeHwIndex];
1106
+ if(!inst || !inst.ctx) return;
1107
 
1108
+ inst.ctx.fillStyle = '#ffffff';
1109
+ inst.ctx.fillRect(0, 0, inst.element.width, inst.element.height);
 
1110
 
1111
+ inst.strokes = [];
1112
+ // 如果只有一個畫布,也清空輸入框,多個畫布時不清空輸入框以免誤刪
1113
+ if (hwInstances.length === 1) {
1114
+ answerInput.value = '';
1115
+ }
1116
  candidateBar.innerHTML = '';
1117
  candidateBar.classList.add('hidden');
1118
  }
1119
 
1120
+ // [修改] 復原當前畫布上一筆
1121
  function undoStroke() {
1122
+ const inst = hwInstances[activeHwIndex];
1123
+ if (!inst || inst.strokes.length === 0) return;
1124
+ inst.strokes.pop();
1125
+ redrawCanvas(inst);
1126
  }
1127
 
1128
+ // [修改] 重繪特定畫布
1129
+ function redrawCanvas(inst) {
1130
+ if(!inst || !inst.ctx) return;
 
1131
 
1132
+ inst.ctx.fillStyle = '#ffffff';
1133
+ inst.ctx.fillRect(0, 0, inst.element.width, inst.element.height);
 
1134
 
1135
+ inst.ctx.beginPath();
1136
+ inst.strokes.forEach(stroke => {
 
1137
  const xs = stroke[0];
1138
  const ys = stroke[1];
1139
  if (xs.length > 0) {
1140
+ inst.ctx.moveTo(xs[0], ys[0]);
1141
  for (let i = 1; i < xs.length; i++) {
1142
+ inst.ctx.lineTo(xs[i], ys[i]);
1143
  }
1144
  }
1145
  });
1146
+ inst.ctx.stroke();
1147
  }
1148
 
1149
  // [新增] 載入 TrOCR AI 模型 (使用 dynamic import)
 
1184
 
1185
  // [修正] 辨識手寫 (改用 TrOCR)
1186
  async function recognizeHandwriting() {
1187
+ // 檢查是否有任何筆跡
1188
+ const hasStrokes = hwInstances.some(inst => inst.strokes.length > 0);
1189
+ if (!hasStrokes) {
1190
  showToast("請先手寫內容!");
1191
  return;
1192
  }
 
1201
  hwRecognizeBtn.textContent = "AI 辨識中...";
1202
 
1203
  try {
1204
+ let finalParts = [];
1205
+ let hwIndex = 0;
1206
+
1207
+ // 依據 currentHandwritingStructure 組裝答案
1208
+ // 結構範例: ['cheer', '...', 'on']
1209
+ // hwInstances 對應: [Canvas1(cheer), Canvas2(on)]
1210
+
1211
+ // 為了平行處理提升速度,先建立所有的 Promise
1212
+ const recognitionPromises = hwInstances.map(async (inst) => {
1213
+ if (inst.strokes.length === 0) return ""; // 空畫布回傳空字串
1214
+ const imageData = inst.element.toDataURL("image/png");
1215
+ const output = await ocrPipeline(imageData);
1216
+ let text = output[0]?.generated_text?.trim() || "";
1217
+ return text.replace(/\.$/, ''); // 移除句點
1218
+ });
1219
 
1220
+ // 等待所有畫布辨識完成
1221
+ const recognizedTexts = await Promise.all(recognitionPromises);
1222
+
1223
+ // 依照結構組裝
1224
+ let recIndex = 0;
1225
+ currentHandwritingStructure.forEach(part => {
1226
+ if (part === '...') {
1227
+ finalParts.push('...');
1228
+ } else {
1229
+ // 取出對應的辨識結果
1230
+ let text = recognizedTexts[recIndex] || "";
1231
+ if (text) finalParts.push(text);
1232
+ recIndex++;
1233
+ }
1234
+ });
1235
+
1236
+ // 組合最終字串 (如果是單字就是單字,片語中間加空白,省略符號兩邊加空白)
1237
+ // 這裡的邏輯:把 finalParts 用空白連接即可,因為結構已經決定了順序
1238
+ // 例如: ['recognized_cheer', '...', 'recognized_on'] -> "recognized_cheer ... recognized_on"
1239
+ // 例如: ['come', 'true'] -> "come true"
1240
 
1241
+ const cleanedText = finalParts.join(' ');
1242
 
1243
+ if (cleanedText.trim()) {
 
 
 
1244
  answerInput.value = cleanedText;
1245
  answerInput.classList.add('border-green-500', 'bg-green-50');
1246
  setTimeout(() => answerInput.classList.remove('border-green-500', 'bg-green-50'), 500);
1247
  showToast("辨識結果:" + cleanedText);
 
 
1248
  showCandidates([cleanedText]);
1249
  } else {
1250
  showToast("未能辨識出文字");
 
1283
 
1284
 
1285
  // [新增] 更新輸入介面 (控制手寫板顯示與輸入法)
1286
+ // 參數 targetAnswer: 正確答案字串,用來判斷是否需要多重畫布
1287
+ function updateInputInterface(targetLanguage, targetAnswer = "") {
1288
  const hwContainer = document.getElementById('handwriting-container');
1289
 
1290
  // [修改] 為了方便在 PC 上除錯,暫時移除了 effectiveVersion === 'mobile' 的檢查
1291
  // 現在只要是輸入英文模式 (expectedLang === 'en'),都會顯示手寫板
1292
+ const shouldUseHandwriting = (targetLanguage === 'en');
1293
 
1294
  if (shouldUseHandwriting) {
1295
  // --- 啟用手寫模式 ---
 
1296
  hwContainer.classList.remove('hidden');
1297
  hwContainer.classList.add('flex');
1298
 
1299
+ // 分析目標單字結構
1300
+ // 1. 移除詞性標記 (v.) 等
1301
+ let cleanAnswer = targetAnswer.replace(/\(.*\)/g, '').trim();
1302
+
1303
+ // 2. 判斷結構
1304
+ let structure = [];
1305
+ if (cleanAnswer.includes('...')) {
1306
+ // 分離式片語: cheer ... on -> ['cheer', '...', 'on']
1307
+ // 使用正則分割,但保留分隔符
1308
+ // 簡單做法:先拆分
1309
+ const parts = cleanAnswer.split('...');
1310
+ parts.forEach((p, i) => {
1311
+ if (p.trim()) structure.push(p.trim());
1312
+ if (i < parts.length - 1) structure.push('...');
1313
+ });
1314
+ } else if (cleanAnswer.includes(' ')) {
1315
+ // 一般片語: come true -> ['come', 'true']
1316
+ structure = cleanAnswer.split(/\s+/).filter(s => s);
1317
+ } else {
1318
+ // 單字: apple -> ['apple']
1319
+ structure = [cleanAnswer];
1320
+ }
1321
+
1322
+ // 如果 structure 是空的 (例如意外狀況),預設一個
1323
+ if (structure.length === 0) structure = ['word'];
1324
+
1325
+ currentHandwritingStructure = structure;
1326
+
1327
  // 2. 初始化 (延遲以確保 flex 渲染完成)
1328
+ setTimeout(() => initMultiHandwritingBoard(structure), 400);
 
1329
 
1330
  // 3. 觸發模型預載 (Lazy Load)
 
1331
  if (!ocrPipeline && !isModelLoading) {
1332
  initOCR();
1333
  }
 
1336
  answerInput.readOnly = true;
1337
  answerInput.placeholder = "請在下方手寫英文...";
1338
  answerInput.classList.add('handwriting-mode');
1339
+ if (document.activeElement === answerInput) answerInput.blur();
 
 
 
 
1340
 
1341
  } else {
1342
  // --- 啟用鍵盤模式 (PC 或 輸入中文) ---
 
1344
  hwContainer.classList.remove('flex');
1345
 
1346
  answerInput.readOnly = false;
1347
+ answerInput.placeholder = (targetLanguage === 'zh') ? "輸入中文答案 (按 Enter 提交)" : "輸入英文答案 (按 Enter 提交)";
1348
  answerInput.classList.remove('handwriting-mode');
1349
  }
1350
 
1351
+ console.log(`輸入模式: ${shouldUseHandwriting ? '手寫' : '鍵盤'}, 結構:`, currentHandwritingStructure);
1352
  }
1353
 
1354
  // --- 視圖管理 ---
 
1543
 
1544
  let frontText, backText;
1545
  let targetLanguage = 'en'; // 預設輸入英文
1546
+ // [新增] 紀錄正確答案以便生成手寫板結構
1547
+ let targetAnswerForHandwriting = '';
1548
 
1549
  if (isSpeed) {
1550
  // 極速挑戰
 
1553
  frontText = card.chinese;
1554
  backText = card.english;
1555
  targetLanguage = 'en';
1556
+ targetAnswerForHandwriting = card.english;
1557
  break;
1558
  case 'en-zh':
1559
  frontText = card.english;
 
1567
  [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
1568
  speakWord(card);
1569
  targetLanguage = 'en';
1570
+ targetAnswerForHandwriting = card.english;
1571
  break;
1572
  }
1573
  } else {
 
1582
  // 複習模式下:沒翻面(看英文)=輸入中文,翻面(看中文)=輸入英文
1583
  const isFlipped = flashcardContainer.classList.contains('flipped');
1584
  targetLanguage = isFlipped ? 'en' : 'zh';
1585
+ // 複習模式翻面輸入英文時,目標答案是英文
1586
+ if (targetLanguage === 'en') targetAnswerForHandwriting = card.english;
1587
  break;
1588
  case 'zh-en': case 'hard':
1589
  frontText = card.chinese; backText = card.english;
1590
  targetLanguage = 'en';
1591
+ targetAnswerForHandwriting = card.english;
1592
  break;
1593
  case 'en-zh':
1594
  frontText = card.english; backText = card.chinese;
 
1600
  [speakBtn, speakSlowBtn].forEach(btn => btn.classList.remove('hidden'));
1601
  speakWord(card);
1602
  targetLanguage = 'en';
1603
+ targetAnswerForHandwriting = card.english;
1604
  break;
1605
  case 'sentence-cloze':
1606
  clozeQuestionContainer.classList.remove('hidden');
 
1610
  toggleTranslationBtn.textContent = '顯示翻譯';
1611
  backText = card.sentence.answer || card.english;
1612
  targetLanguage = 'en';
1613
+ targetAnswerForHandwriting = card.sentence.answer;
1614
  break;
1615
  }
1616
  }
1617
 
1618
+ // [新增] 傳入目標答案以判斷手寫板數量
1619
+ updateInputInterface(targetLanguage, targetAnswerForHandwriting);
1620
 
1621
  if (frontText) {
1622
  frontDisplay.classList.remove('hidden');
 
1922
  endSpeedChallenge();
1923
  }
1924
  };
1925
+
1926
+ // ... (rest of the code remains the same)
1927
+ // 為了節省篇幅,後續程式碼保持不變
1928
+ // 包括 speakWord, initializeApp 等
1929
+ // ...
1930
 
1931
  const endSpeedChallenge = () => {
1932
  clearInterval(timerInterval);
 
2374
 
2375
  // [智慧輸入切換] 複習模式下:翻面後看到中文->要輸入英文
2376
  const targetLanguage = isFlipped ? 'en' : 'zh';
2377
+ // 複習模式下,翻到英文面輸入中文,翻到中文面輸入英文
2378
+ // 這裡的邏輯是: 沒翻面(看英文)->輸入中文(zh), 翻面(看中文)->輸入英文(en)
2379
+ // 所以 targetLanguage 已經是正確的目標語言
2380
+ // 但我們需要知道正確答案是什麼,才能判斷手寫板結構
2381
+ let targetAnswer = '';
2382
+ if (targetLanguage === 'en') {
2383
+ targetAnswer = wordsForCurrentMode[currentCardIndex].english;
2384
+ }
2385
+
2386
+ updateInputInterface(targetLanguage, targetAnswer);
2387
 
2388
  feedbackDisplay.textContent = '';
2389
  setTimeout(() => answerInput.focus(), 100);
 
2747
  } else {
2748
  // 【修改】使用新的預設單字,展示文法感知功能
2749
  const defaultWords = [
2750
+ {
2751
+ english: 'cheer ... on',
2752
+ chinese: '為...加油',
2753
+ sentence: {
2754
+ en: 'We ___ the team ___.',
2755
+ zh: '我們為這支隊伍加油。',
2756
+ answer: 'cheered ... on'
2757
+ }
2758
+ },
2759
+ {
2760
+ english: 'come true',
2761
+ chinese: '成真'
2762
+ },
2763
  {
2764
  english: 'watch (v.)',
2765
  chinese: '觀看',
 
2769
  answer: 'watches'
2770
  }
2771
  },
 
 
 
 
 
 
 
 
 
2772
  { english: 'study (v.)', chinese: '研讀' },
2773
  { english: 'last (adj.)', chinese: '前一個的' },
 
 
 
 
2774
  ];
2775
  words = defaultWords.map(word => ({ ...word, proficiency: 0, incorrectCount: 0 }));
2776
  saveWordsToStorage();