// 各ページ共通のユーティリティ (function () { 'use strict'; function lsGet(key) { try { return localStorage.getItem(key); } catch { return null; } } function lsSet(key, value) { try { localStorage.setItem(key, value); } catch { /* プライベートモード等 */ } } // 非HTTPS環境でも動くよう crypto.randomUUID ではなく getRandomValues を使う function randomHex(bytes) { const a = new Uint8Array(bytes); crypto.getRandomValues(a); return Array.from(a, (b) => b.toString(16).padStart(2, '0')).join(''); } const Store = { get name() { return lsGet('poker.name') || ''; }, set name(v) { lsSet('poker.name', v); }, // 席の再接続用。ブラウザごとに固定 get token() { let t = lsGet('poker.token'); if (!t || !/^[0-9a-f]{32}$/.test(t)) { t = randomHex(16); lsSet('poker.token', t); } return t; }, }; function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function chips(n) { return '$' + Number(n).toLocaleString('en-US'); } // 上から降りてきて、少ししたら同じ道を上へ戻る通知 let toastTimer; function toast(msg) { let el = document.querySelector('.toast'); if (!el) { el = document.createElement('div'); el.className = 'toast'; el.setAttribute('role', 'status'); el.setAttribute('aria-live', 'polite'); document.body.appendChild(el); void el.offsetWidth; // 最初の1回も画面の外から動かす } el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2600); } // ブラウザ標準の confirm() の代わりの確認ダイアログ(iOS のアラートの形)。OK なら true で解決する。 // opts: { title, message(改行可), ok, cancel, danger(OK を赤にする) } // 新しいダイアログを開くと、開いていたものはキャンセル扱いで閉じる let closeConfirm = null; function confirmDialog(opts) { const o = typeof opts === 'string' ? { message: opts } : opts; if (closeConfirm) closeConfirm(false); const backdrop = document.createElement('div'); backdrop.className = 'alert-backdrop'; backdrop.innerHTML = '
' + '
' + (o.title ? '

' : '') + '

' + '
' + '
' + '' + `` + '
'; const dialog = backdrop.firstChild; const msgEl = dialog.querySelector('.alert-message'); msgEl.textContent = o.message || ''; msgEl.id = 'confirm-message-' + randomHex(4); dialog.setAttribute('aria-describedby', msgEl.id); if (o.title) { const titleEl = dialog.querySelector('.alert-title'); titleEl.textContent = o.title; titleEl.id = 'confirm-title-' + randomHex(4); dialog.setAttribute('aria-labelledby', titleEl.id); } const okBtn = dialog.querySelector('[data-confirm="ok"]'); const cancelBtn = dialog.querySelector('[data-confirm="cancel"]'); okBtn.textContent = o.ok || 'OK'; cancelBtn.textContent = o.cancel || 'キャンセル'; // 現れるときは少し大きい所から縮み、消えるときはその場で薄くなる let closing = false; const appear = new Fluid.Spring(0, { precision: 0.002 }); appear.onUpdate = (v) => { backdrop.style.setProperty('--appear', v.toFixed(3)); dialog.style.opacity = v >= 1 ? '' : v.toFixed(3); dialog.style.transform = closing || Fluid.reducedMotion() ? '' : `scale(${(1.12 - 0.12 * v).toFixed(4)})`; }; const prevFocus = document.activeElement; return new Promise((resolve) => { function close(result) { if (closeConfirm !== close) return; closeConfirm = null; closing = true; document.removeEventListener('keydown', onKey, true); backdrop.style.pointerEvents = 'none'; appear.to(0, { damping: 1, response: 0.2, allowReduced: true, onRest: () => backdrop.remove() }); if (prevFocus && prevFocus.focus && document.contains(prevFocus)) prevFocus.focus(); resolve(result); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(false); } else if (e.key === 'Tab') { // フォーカスをダイアログの2つのボタンの中に閉じ込める e.preventDefault(); (document.activeElement === okBtn ? cancelBtn : okBtn).focus(); } } okBtn.addEventListener('click', () => close(true)); cancelBtn.addEventListener('click', () => close(false)); // 背景を押したらキャンセル(ダイアログ内から背景へのドラッグは除く) let downOnBackdrop = false; backdrop.addEventListener('pointerdown', (e) => { downOnBackdrop = e.target === backdrop; }); backdrop.addEventListener('click', (e) => { if (e.target === backdrop && downOnBackdrop) close(false); }); document.addEventListener('keydown', onKey, true); closeConfirm = close; document.body.appendChild(backdrop); appear.set(0); appear.to(1, { damping: 1, response: 0.3, allowReduced: true }); // 危険な操作はうっかり Enter で確定しないようキャンセルにフォーカスする (o.danger ? cancelBtn : okBtn).focus(); }); } async function api(method, url, body) { const res = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin', }); if (res.status === 401 && url !== '/api/auth') { location.href = '/key?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'エラーが発生しました'); return data; } // 文章をクリップボードにコピーする。load は文章を返す Promise を作る関数。 // サーバから取ってくる間にボタンを押した扱いが切れる(iOS Safari など)ので、まず ClipboardItem に // Promise のまま渡し、だめなら writeText、それもだめなら文章を選択した状態で表示して手でコピーしてもらう async function copyText(load) { const pending = load(); try { if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) { const blob = pending.then((t) => new Blob([t], { type: 'text/plain' })); await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]); return true; } } catch { /* 次の方法へ */ } const text = await pending; // 文章を取れなかったときはここで呼び出し元へエラーを返す try { await navigator.clipboard.writeText(text); return true; } catch { /* 手でコピーしてもらう */ } showCopyDialog(text); return false; } function showCopyDialog(text) { const root = document.createElement('div'); root.className = 'sheet-backdrop'; root.hidden = true; root.innerHTML = ''; const area = root.querySelector('textarea'); area.value = text; document.body.appendChild(root); const sheet = Fluid.Sheet(root, { onClosed: () => root.remove() }); root.querySelector('.copy-close').addEventListener('click', () => sheet.close()); sheet.open(); area.focus({ preventScroll: true }); area.select(); area.scrollTop = 0; // select() で末尾までスクロールするので先頭に戻す } // サーバは WebSocket のみ受け付ける。接続を拒否されたら鍵が無効になっていないか確認する function connectSocket() { const socket = io({ transports: ['websocket'] }); socket.on('connect_error', () => { fetch('/api/tables', { credentials: 'same-origin' }) .then((res) => { if (res.status === 401) location.href = '/key?next=' + encodeURIComponent(location.pathname); }) .catch(() => {}); }); return socket; } // 自サイト内のパスだけ許可("//x" や "/\x" は外部サイト扱いになるので拒否) function safeNext(next) { return typeof next === 'string' && /^\/(?![/\\])[^\\\s]*$/.test(next) && next.length <= 200 ? next : '/'; } const PHASE_LABELS = { WAITING: 'WAITING', PREFLOP: 'PREFLOP', FLOP: 'FLOP', TURN: 'TURN', RIVER: 'RIVER', SHOWDOWN: 'SHOWDOWN', FINISHING: 'FINISHING', }; window.App = { Store, esc, chips, toast, confirm: confirmDialog, api, copyText, connectSocket, safeNext, PHASE_LABELS }; })();