Spaces:
Running
Running
Download index.html from Lashtw/worldcloud: direct link, hf CLI and curl.
- Browser
- Download file 22.2 kB
-
https://huggingface.co/spaces/Lashtw/worldcloud/resolve/main/index.html
- Command line
-
hf download hf://spaces/Lashtw/worldcloud/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Lashtw/worldcloud/resolve/main/index.html
22.2 kB
| <html lang="zh-Hant"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>即時互動文字雲 - 教師主控端</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;800;900&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.0.2/wordcloud2.min.js"></script> | |
| <style> | |
| body { | |
| font-family: 'Noto Sans TC', sans-serif; | |
| } | |
| .overlay { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| z-index: 1000; | |
| background: rgba(15, 23, 42, 0.6); | |
| backdrop-filter: blur(4px); | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .overlay.active { | |
| display: flex; | |
| } | |
| /* Custom Scrollbar for list panels */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f5f9; | |
| border-radius: 8px; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #cbd5e1; | |
| border-radius: 8px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #94a3b8; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-slate-100 text-slate-800 h-screen overflow-hidden flex flex-col"> | |
| <div id="loading" class="fixed inset-0 bg-slate-900/80 text-white flex flex-col items-center justify-center z-[2000] transition-opacity duration-300"> | |
| <div class="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full animate-spin mb-4"></div> | |
| <p class="text-lg font-medium tracking-wider">正在載入文字雲系統...</p> | |
| </div> | |
| <div class="flex-1 flex flex-col lg:flex-row h-full overflow-hidden"> | |
| <!-- ================= 左側:大畫布文字雲區域 ================= --> | |
| <main class="flex-1 flex flex-col p-4 lg:p-6 overflow-hidden relative min-h-0"> | |
| <!-- 頂部資訊條:題目與總數統計 --> | |
| <header class="flex items-center justify-between bg-white px-6 py-3.5 rounded-2xl shadow-sm border border-slate-200/80 mb-4 shrink-0"> | |
| <div class="flex items-center gap-3 overflow-hidden mr-4"> | |
| <span class="text-xl">📌</span> | |
| <h2 id="questionDisplay" class="text-base sm:text-lg font-bold text-slate-700 truncate"> | |
| 等待教師發布題目... | |
| </h2> | |
| </div> | |
| <div class="flex items-center gap-2 bg-orange-50 px-4 py-1.5 rounded-xl border border-orange-200 shrink-0"> | |
| <span class="text-xs sm:text-sm font-semibold text-orange-600">總詞彙數量</span> | |
| <span id="wordCount" class="text-lg sm:text-xl font-extrabold text-orange-600">0</span> | |
| </div> | |
| </header> | |
| <!-- Canvas 畫布容器 --> | |
| <div id="cloud-container" class="flex-1 bg-white rounded-2xl shadow-sm border border-slate-200/80 relative overflow-hidden flex items-center justify-center"> | |
| <canvas id="word-canvas" class="w-full h-full cursor-pointer block"></canvas> | |
| <!-- 右下角開發者資訊標籤 --> | |
| <div class="absolute bottom-4 right-4 bg-white/90 backdrop-blur border border-slate-200/80 px-3.5 py-1.5 rounded-xl text-xs text-slate-500 shadow-sm z-10 flex items-center gap-2 select-none"> | |
| <span>程式設計者:精華國中 藍星宇</span> | |
| <span class="text-slate-300">|</span> | |
| <a href="https://www.facebook.com/groups/1554372228718393" target="_blank" rel="noopener" class="text-blue-600 hover:underline font-medium"> | |
| 臉書社團:萬物皆數 | |
| </a> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- ================= 右側:控制中心工具欄 ================= --> | |
| <aside class="w-full lg:w-80 xl:w-96 bg-white border-t lg:border-t-0 lg:border-l border-slate-200 p-5 flex flex-col justify-between gap-5 overflow-y-auto shrink-0 z-20"> | |
| <!-- 上半部:標題、QR Code 與發布區域 --> | |
| <div class="space-y-5"> | |
| <!-- 標頭列 --> | |
| <div class="flex items-center justify-between border-b border-slate-100 pb-3"> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-2xl">☁️</span> | |
| <div> | |
| <h1 class="text-lg font-bold text-slate-800 leading-tight">文字雲主控端</h1> | |
| <p class="text-xs text-slate-400">即時互動與詞彙視覺化工具</p> | |
| </div> | |
| </div> | |
| <div class="bg-orange-500 text-white font-black text-sm px-3 py-1 rounded-xl shadow-sm"> | |
| 房號:<span id="roomCodeDisplay">----</span> | |
| </div> | |
| </div> | |
| <!-- QR Code 掃碼加入區 --> | |
| <div class="bg-slate-50 border border-slate-200/80 rounded-2xl p-4 flex flex-col items-center"> | |
| <p class="text-xs font-semibold text-slate-500 mb-2">學生掃描加入</p> | |
| <div class="p-2.5 bg-white rounded-xl border border-slate-200 shadow-sm mb-3"> | |
| <div id="qrcode" class="flex justify-center items-center"></div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-2 w-full"> | |
| <button id="btnCopyLink" class="px-3 py-2 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all shadow-sm flex items-center justify-center gap-1"> | |
| 📋 複製連結 | |
| </button> | |
| <button id="btnOpenGuest" class="px-3 py-2 bg-white hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all shadow-sm flex items-center justify-center gap-1"> | |
| 🔗 開啟學員端 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 發布討論題目 --> | |
| <div class="space-y-2"> | |
| <label for="questionInput" class="text-xs font-bold text-slate-600 block">設定討論題目</label> | |
| <div class="flex gap-2"> | |
| <input id="questionInput" type="text" placeholder="輸入題目,學生端同步顯示..." class="flex-1 px-3 py-2 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400 focus:bg-white transition-all"> | |
| <button id="btnQuestion" class="px-4 py-2 bg-orange-500 hover:bg-orange-600 active:scale-95 text-white font-semibold text-sm rounded-xl transition-all shadow-sm shrink-0"> | |
| 送出 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- 房間設定開關 --> | |
| <div class="space-y-3 border-t border-slate-100 pt-3"> | |
| <div class="flex items-center justify-between p-2.5 rounded-xl hover:bg-slate-50 transition-colors"> | |
| <div> | |
| <div class="text-xs font-bold text-slate-700">🔒 鎖定房間</div> | |
| <div class="text-[11px] text-slate-400">開啟後將暫停接收學生新回應</div> | |
| </div> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="lockToggle" class="sr-only peer"> | |
| <div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-500"></div> | |
| </label> | |
| </div> | |
| <div class="flex items-center justify-between p-2.5 rounded-xl hover:bg-slate-50 transition-colors"> | |
| <div> | |
| <div class="text-xs font-bold text-slate-700">🔄 允許多次送出</div> | |
| <div class="text-[11px] text-slate-400">學生可重複提交多個詞彙</div> | |
| </div> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="toggleSwitch" class="sr-only peer"> | |
| <div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-500"></div> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 下半部:工具選單按鈕區 --> | |
| <div class="space-y-2 border-t border-slate-100 pt-3"> | |
| <div class="text-xs font-bold text-slate-400 mb-2">工具選單</div> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <button id="btnFullScreen" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1"> | |
| ⛶ 全螢幕 | |
| </button> | |
| <button id="btnScreenshot" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1"> | |
| 📸 截圖 | |
| </button> | |
| <button id="btnCSV" class="px-3 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-xl transition-all flex items-center justify-center gap-1"> | |
| 📄 匯出 CSV | |
| </button> | |
| </div> | |
| <button id="btnReset" class="w-full py-2.5 bg-red-50 hover:bg-red-100 text-red-600 border border-red-200 font-semibold text-xs rounded-xl transition-all shadow-sm mt-2 flex items-center justify-center gap-1"> | |
| 🗑 清除重來 | |
| </button> | |
| </div> | |
| </aside> | |
| </div> | |
| <!-- 詞彙選擇列表彈窗 --> | |
| <div class="overlay" id="pickOverlay"> | |
| <div class="bg-white p-6 rounded-2xl max-w-lg w-[90%] max-h-[75vh] flex flex-col shadow-2xl"> | |
| <h3 class="text-lg font-bold text-slate-800 mb-3">選擇要檢視或放大的詞彙:</h3> | |
| <div id="pickList" class="flex-1 overflow-y-auto space-x-1 space-y-2 py-2"></div> | |
| <div class="mt-4 text-right border-t pt-3"> | |
| <button id="pickClose" class="px-5 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors"> | |
| 取消 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 詞彙放大顯示彈窗 (加寬加高優化) --> | |
| <div class="overlay" id="zoomOverlay"> | |
| <div class="bg-white p-8 md:p-10 rounded-3xl max-w-2xl md:max-w-3xl lg:max-w-4xl w-[92%] text-center shadow-2xl transition-all"> | |
| <div id="zoomText" class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-slate-800 break-words leading-snug my-6 md:my-8 px-2 max-h-[60vh] overflow-y-auto"></div> | |
| <div class="flex gap-3 justify-center border-t border-slate-100 pt-6"> | |
| <button id="zoomDelete" class="px-6 py-2.5 bg-red-500 hover:bg-red-600 text-white text-sm font-semibold rounded-xl transition-colors shadow-sm"> | |
| 🗑 刪除此詞彙 | |
| </button> | |
| <button id="zoomClose" class="px-6 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors"> | |
| 關閉 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 清除數據確認彈窗 --> | |
| <div class="overlay" id="resetConfirmOverlay"> | |
| <div class="bg-white p-6 rounded-2xl max-w-sm w-[90%] text-center shadow-2xl"> | |
| <div class="text-3xl mb-2">⚠️</div> | |
| <h3 class="text-lg font-bold text-slate-800 mb-2">重置所有資料?</h3> | |
| <p class="text-sm text-slate-500 mb-6">這將會清除當前畫面上所有的文字雲詞彙,此動作無法復原。</p> | |
| <div class="flex gap-3 justify-center"> | |
| <button id="btnConfirmReset" class="flex-1 py-2.5 bg-red-500 hover:bg-red-600 text-white text-sm font-semibold rounded-xl transition-colors"> | |
| 確定清除 | |
| </button> | |
| <button id="btnCancelReset" class="flex-1 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-semibold rounded-xl transition-colors"> | |
| 取消 | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 複製成功 Toast 提示 --> | |
| <div id="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-900/90 text-white px-5 py-2.5 rounded-2xl shadow-xl text-sm font-medium transition-all opacity-0 pointer-events-none z-[3000]"> | |
| 已複製學員端連結! | |
| </div> | |
| <script type="module"> | |
| import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js'; | |
| import { getFirestore, doc, setDoc, onSnapshot, updateDoc, deleteField } from 'https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js'; | |
| // Firebase Configuration | |
| const firebaseConfig = { | |
| apiKey: "AIzaSyAcIQYst9gFRyE2nCqCwNgCL3LG2VkBXbM", | |
| authDomain: "factor-529c9.firebaseapp.com", | |
| projectId: "factor-529c9", | |
| storageBucket: "factor-529c9.firebasestorage.app", | |
| messagingSenderId: "575829635896", | |
| appId: "1:575829635896:web:a65b6edd9d163375b087cd" | |
| }; | |
| const app = initializeApp(firebaseConfig); | |
| const db = getFirestore(app); | |
| // Application State Variables | |
| const roomCode = Math.floor(1000 + Math.random() * 9000).toString(); | |
| let allowMultiple = false; | |
| let isLocked = false; | |
| let wordData = {}; | |
| let isDrawing = false; | |
| let drawQueue = false; | |
| let selectedWord = null; | |
| // UI Elements Reference | |
| const canvas = document.getElementById('word-canvas'); | |
| const container = document.getElementById('cloud-container'); | |
| const roomRef = doc(db, 'rooms', roomCode); | |
| const clientUrl = `${location.origin}${location.pathname.replace(/[^/]*$/, '')}guest.html?room=${roomCode}`; | |
| async function initRoom() { | |
| try { | |
| await setDoc(roomRef, { roomCode, locked: false, allowMultiple: false, words: {}, question: '' }, { merge: true }); | |
| document.getElementById('roomCodeDisplay').textContent = roomCode; | |
| // Listen for real-time Firestore updates | |
| onSnapshot(roomRef, (docSnap) => { | |
| if (!docSnap.exists()) return; | |
| const data = docSnap.data(); | |
| if (data.allowMultiple !== allowMultiple) { | |
| allowMultiple = data.allowMultiple; | |
| document.getElementById('toggleSwitch').checked = !!allowMultiple; | |
| } | |
| if (data.locked !== isLocked) { | |
| isLocked = !!data.locked; | |
| document.getElementById('lockToggle').checked = isLocked; | |
| } | |
| document.getElementById('questionDisplay').textContent = data.question ? data.question : '等待教師發布題目...'; | |
| document.getElementById('wordCount').textContent = Object.keys(data.words || {}).length; | |
| const newWords = data.words || {}; | |
| if (JSON.stringify(newWords) !== JSON.stringify(wordData)) { | |
| wordData = newWords; | |
| queueDraw(); | |
| } | |
| }); | |
| } catch (err) { | |
| console.error("Firebase init error:", err); | |
| } | |
| } | |
| function queueDraw() { | |
| if (isDrawing) { drawQueue = true; return; } | |
| drawCloud(); | |
| } | |
| function drawCloud() { | |
| canvas.width = container.clientWidth; | |
| canvas.height = container.clientHeight; | |
| const list = Object.entries(wordData).map(([w, c]) => [w, c]); | |
| const ctx = canvas.getContext('2d'); | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (list.length === 0) { | |
| ctx.font = '16px "Noto Sans TC"'; | |
| ctx.fillStyle = '#94A3B8'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('等待學生送出詞彙…', canvas.width / 2, canvas.height / 2); | |
| return; | |
| } | |
| isDrawing = true; | |
| // 智慧調適文字大小:詞彙少時大幅放大標題字級,隨詞彙增加自動縮小 | |
| const wordCount = list.length; | |
| const minDim = Math.min(canvas.width, canvas.height); | |
| // 計算最高頻率詞彙 | |
| const maxFreq = Math.max(...list.map(item => item[1])); | |
| WordCloud(canvas, { | |
| list, | |
| gridSize: Math.max(4, Math.floor(minDim / 100)), | |
| weightFactor: (w) => { | |
| // 依總詞數動態選擇基礎縮放比例 | |
| let baseScale; | |
| if (wordCount <= 3) baseScale = minDim * 0.32; | |
| else if (wordCount <= 8) baseScale = minDim * 0.22; | |
| else if (wordCount <= 20) baseScale = minDim * 0.15; | |
| else baseScale = minDim * 0.09; | |
| const relativeWeight = Math.pow(w / maxFreq, 0.7); | |
| return Math.max(16, relativeWeight * baseScale); | |
| }, | |
| fontFamily: 'Noto Sans TC, sans-serif', | |
| color: () => ['#F97316', '#3B82F6', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B', '#06B6D4'][Math.floor(Math.random() * 7)], | |
| rotateRatio: 0.15, | |
| minSize: 12, | |
| weightMode: 'size', | |
| shape: 'circle', | |
| ellipticity: 0.75, | |
| backgroundColor: 'transparent' | |
| }); | |
| } | |
| // 監聽 WordCloud 畫布繪製完成事件 | |
| canvas.addEventListener('wordcloudstop', function onStop() { | |
| isDrawing = false; | |
| if (drawQueue) { | |
| drawQueue = false; | |
| setTimeout(drawCloud, 300); | |
| } | |
| }); | |
| canvas.addEventListener('click', () => { | |
| if (Object.keys(wordData).length === 0) return; | |
| const listEl = document.getElementById('pickList'); | |
| listEl.innerHTML = ''; | |
| Object.keys(wordData).sort((a, b) => wordData[b] - wordData[a]).forEach(word => { | |
| const chip = document.createElement('span'); | |
| chip.className = 'inline-block m-1.5 px-4 py-2 text-sm font-medium bg-amber-50 hover:bg-amber-100 border border-amber-200 text-amber-900 rounded-2xl cursor-pointer transition-colors shadow-sm'; | |
| chip.textContent = `${word} (${wordData[word]})`; | |
| chip.addEventListener('click', () => { hidePick(); showZoom(word); }); | |
| listEl.appendChild(chip); | |
| }); | |
| document.getElementById('pickOverlay').classList.add('active'); | |
| }); | |
| function hidePick() { document.getElementById('pickOverlay').classList.remove('active'); } | |
| document.getElementById('pickClose').addEventListener('click', hidePick); | |
| function showZoom(word) { | |
| selectedWord = word; | |
| document.getElementById('zoomText').textContent = word; | |
| document.getElementById('zoomOverlay').classList.add('active'); | |
| } | |
| function hideZoom() { | |
| document.getElementById('zoomOverlay').classList.remove('active'); | |
| selectedWord = null; | |
| } | |
| document.getElementById('zoomClose').addEventListener('click', hideZoom); | |
| document.getElementById('zoomOverlay').addEventListener('click', e => { | |
| if (e.target === document.getElementById('zoomOverlay')) hideZoom(); | |
| }); | |
| document.getElementById('zoomDelete').addEventListener('click', async () => { | |
| if (!selectedWord) return; | |
| await updateDoc(roomRef, { words: { [selectedWord]: deleteField() } }); | |
| hideZoom(); | |
| }); | |
| document.getElementById('lockToggle').addEventListener('change', async () => { | |
| isLocked = document.getElementById('lockToggle').checked; | |
| await updateDoc(roomRef, { locked: isLocked }); | |
| }); | |
| document.getElementById('toggleSwitch').addEventListener('change', async () => { | |
| allowMultiple = document.getElementById('toggleSwitch').checked; | |
| await updateDoc(roomRef, { allowMultiple }); | |
| }); | |
| document.getElementById('btnQuestion').addEventListener('click', async () => { | |
| const text = document.getElementById('questionInput').value.trim(); | |
| await updateDoc(roomRef, { question: text }); | |
| document.getElementById('questionInput').value = ''; | |
| }); | |
| document.getElementById('btnFullScreen').addEventListener('click', () => { | |
| if (!document.fullscreenElement) document.documentElement.requestFullscreen(); | |
| else document.exitFullscreen(); | |
| }); | |
| document.getElementById('btnScreenshot').addEventListener('click', () => { | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = canvas.width; | |
| tempCanvas.height = canvas.height; | |
| const tCtx = tempCanvas.getContext('2d'); | |
| tCtx.fillStyle = '#FFFFFF'; | |
| tCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| tCtx.drawImage(canvas, 0, 0); | |
| const dataUrl = tempCanvas.toDataURL("image/png"); | |
| const a = document.createElement('a'); | |
| a.href = dataUrl; | |
| a.download = `即時互動文字雲_${roomCode}.png`; | |
| a.click(); | |
| }); | |
| document.getElementById('btnCSV').addEventListener('click', () => { | |
| const rows = [['詞彙', '次數'], ...Object.entries(wordData)]; | |
| const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); | |
| const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); | |
| const u = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = u; | |
| a.download = `文字雲統計_${roomCode}.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(u); | |
| }); | |
| // 清除重來:彈出自訂 Modal | |
| document.getElementById('btnReset').addEventListener('click', () => { | |
| document.getElementById('resetConfirmOverlay').classList.add('active'); | |
| }); | |
| document.getElementById('btnCancelReset').addEventListener('click', () => { | |
| document.getElementById('resetConfirmOverlay').classList.remove('active'); | |
| }); | |
| document.getElementById('btnConfirmReset').addEventListener('click', async () => { | |
| document.getElementById('resetConfirmOverlay').classList.remove('active'); | |
| try { await updateDoc(roomRef, { words: {} }); } | |
| catch (e) { console.error("Reset failed:", e); } | |
| }); | |
| // 複製連結與 Toast | |
| function showToast(msg) { | |
| const toast = document.getElementById('toast'); | |
| toast.textContent = msg; | |
| toast.style.opacity = '1'; | |
| setTimeout(() => { toast.style.opacity = '0'; }, 2200); | |
| } | |
| document.getElementById('btnCopyLink').addEventListener('click', () => { | |
| const tempInput = document.createElement('input'); | |
| tempInput.value = clientUrl; | |
| document.body.appendChild(tempInput); | |
| tempInput.select(); | |
| document.execCommand('copy'); | |
| document.body.removeChild(tempInput); | |
| showToast('已複製學員端連結!'); | |
| }); | |
| document.getElementById('btnOpenGuest').addEventListener('click', () => { | |
| window.open(clientUrl, '_blank', 'noopener'); | |
| }); | |
| window.addEventListener('load', () => { | |
| initRoom(); | |
| // 產生乾淨專屬 QR Code | |
| try { | |
| new QRCode(document.getElementById('qrcode'), { | |
| text: clientUrl, | |
| width: 140, | |
| height: 140, | |
| colorDark: "#1E293B", | |
| colorLight: "#FFFFFF", | |
| correctLevel: QRCode.CorrectLevel.M | |
| }); | |
| } catch (err) { | |
| console.error("QRCode Error:", err); | |
| } | |
| // 視窗尺寸改變時重繪畫布 | |
| const resizeObserver = new ResizeObserver(() => { | |
| queueDraw(); | |
| }); | |
| resizeObserver.observe(container); | |
| // 淡出並隱藏載入遮罩 | |
| const loading = document.getElementById('loading'); | |
| loading.style.opacity = '0'; | |
| setTimeout(() => loading.style.display = 'none', 300); | |
| }); | |
| </script> | |
| <script> | |
| window.MathJax = { | |
| tex: { inlineMath: [['$','$'],['\\(','\\)']], displayMath: [['$$','$$'],['\\[','\\]']], processEscapes: true }, | |
| options: { skipHtmlTags: ['script','noscript','style','textarea','pre'] } | |
| }; | |
| </script> | |
| <script async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script> | |
| </body> | |
| </html> |