// yy_toast_tts.js.php //
// // (function (w, d) { "use strict"; if (w.YYToastTTS) return; // 중복 로드 방지 const YYToastTTS = { // ========================================================= // [설정부] 기본값 / 상태 // ========================================================= HARD_DEFAULTS: { enabled: true, // 전체 마스터 toastEnabled: true, ttsEnabled: true, vibrateEnabled: false, duration: 5000, // 기본 토스트 표시시간(ms) sticky: false, // 기본 고정 표시 여부 voice: "female", // "female" | "male" lang: "ko-KR", volume: 1, rate: 1, pitch: 1, // 다음 단계(반복 알림) 확장용 예약값 - 지금은 저장만 가능 repeatMode: "once", // once | until_click | for_minutes repeatEverySec: 30, repeatForMin: 10 }, _booted: false, _config: { mount: null, storageKey: "yy_toast_tts_v1", defaults: {} }, settings: null, mountEl: null, voices: [], femaleVoices: [], maleVoices: [], preferredFemaleVoice: null, preferredMaleVoice: null, toasts: new Map(), // toastId -> element _toastSeq: 0, // ========================================================= // [로더부] 시작점 // ========================================================= boot(cfg) { cfg = cfg || {}; this._config.mount = cfg.mount || null; this._config.storageKey = cfg.storageKey || "yy_toast_tts_v1"; this._config.defaults = cfg.defaults || {}; this.settings = this.loadSettings(); this.injectStyles(); this.ensureToastRoot(); this.initVoiceList(); // 음성 목록 갱신 대응 if (w.speechSynthesis) { try { w.speechSynthesis.addEventListener("voiceschanged", () => this.initVoiceList()); } catch (e) { w.speechSynthesis.onvoiceschanged = () => this.initVoiceList(); } } // mount UI (있을 때만) if (this._config.mount) { const mount = d.querySelector(this._config.mount); if (mount) { this.mountEl = mount; this.renderSettingsUI(); this.bindSettingsUI(); this.syncSettingsUI(); } } this._booted = true; return this; }, // ========================================================= // [설정부] 저장/로드 // ========================================================= loadSettings() { let saved = null; try { saved = localStorage.getItem(this._config.storageKey); } catch (e) {} let parsed = {}; if (saved) { try { parsed = JSON.parse(saved) || {}; } catch (e) { parsed = {}; } } // 우선순위: HARD_DEFAULTS < boot.defaults < saved return Object.assign({}, this.HARD_DEFAULTS, this._config.defaults || {}, parsed); }, saveSettings() { try { localStorage.setItem(this._config.storageKey, JSON.stringify(this.settings || {})); } catch (e) {} }, setSettings(patch) { this.settings = Object.assign({}, this.settings || {}, patch || {}); this.saveSettings(); this.syncSettingsUI(); return this.settings; }, getSettings() { return Object.assign({}, this.settings || {}); }, // ========================================================= // [설정부] UI (한 div에 붙는 설정판) // ========================================================= renderSettingsUI() { if (!this.mountEl) return; this.mountEl.innerHTML = `
🔔 Toast + 🔊 TTS 설정
저장은 자동(localStorage)입니다. 이 설정은 ${this.escapeHtml(this._config.storageKey)} 키로 저장됩니다.
`; }, bindSettingsUI() { if (!this.mountEl) return; this.mountEl.addEventListener("click", (e) => { const t = e.target; if (t.closest("[data-yytts-toggle]")) { const body = this.mountEl.querySelector("[data-yytts-body]"); if (!body) return; body.style.display = (body.style.display === "none") ? "" : "none"; t.textContent = (body.style.display === "none") ? "펼치기" : "접기"; return; } if (t.closest("[data-yytts-test]")) { this.toast("안녕하세요. Toast + TTS 테스트입니다.", { type: "success", sticky: false }); return; } }); this.mountEl.addEventListener("change", (e) => { const t = e.target; const key = t.getAttribute("data-k"); if (!key) return; let val; if (t.type === "checkbox") { val = !!t.checked; } else if (t.tagName === "SELECT") { if (t.value === "true") val = true; else if (t.value === "false") val = false; else val = t.value; } else if (t.type === "number") { val = Number(t.value); if (Number.isNaN(val)) val = 0; } else { val = t.value; } this.setSettings({ [key]: val }); }); }, syncSettingsUI() { if (!this.mountEl || !this.settings) return; const root = this.mountEl; const keys = [ "enabled","toastEnabled","ttsEnabled","vibrateEnabled", "duration","sticky","voice","lang","rate","volume","pitch", "repeatMode","repeatEverySec","repeatForMin" ]; keys.forEach((k) => { const el = root.querySelector(`[data-k="${k}"]`); if (!el) return; const v = this.settings[k]; if (el.type === "checkbox") { el.checked = !!v; } else if (el.tagName === "SELECT") { el.value = String(v); } else { el.value = (v == null ? "" : v); } }); }, // ========================================================= // [사용부] 전역 호출 API (toast + tts 세트) // ========================================================= // 사용 예: // toast("메시지") // toast("메시지", { type:"success", sticky:true }) // toast("메시지", { tts:false }, "warning") // toast("메시지", "success") toast(message, options, color) { if (!this._booted) this.boot({}); // mount 없이도 API-only 동작 가능 const opts = this.normalizeToastOptions(message, options, color); const s = this.settings || this.loadSettings(); if (!s.enabled) return null; const effective = { // 저장값 기반 기본 toast: s.toastEnabled, tts: s.ttsEnabled, vibrate: s.vibrateEnabled, duration: Number(s.duration || 5000), sticky: !!s.sticky, type: "info", // 호출 옵션으로 덮어쓰기 ...(opts || {}) }; // sticky면 duration 무시 if (effective.sticky) effective.duration = 0; if (!effective.type && color) effective.type = color; const toastId = `yytts_${++this._toastSeq}_${Date.now()}`; // Toast 출력 if (effective.toast !== false) { this.showToast(toastId, { message: String(message == null ? "" : message), type: effective.type || "info", duration: effective.duration, sticky: !!effective.sticky, buttons: effective.buttons || null, onAction: effective.onAction || null, onClose: effective.onClose || null }); } // TTS 출력 if (effective.tts !== false) { const speakText = this.sanitizeForTTS( effective.ttsText != null ? effective.ttsText : String(message == null ? "" : message) ); if (speakText) this.speak(speakText); } // 진동 if (effective.vibrate && navigator.vibrate) { try { navigator.vibrate([120, 70, 120]); } catch (e) {} } return { id: toastId, close: () => this.closeToast(toastId), update: (patch) => this.updateToast(toastId, patch || {}) }; }, normalizeToastOptions(message, options, color) { // toast("msg", "success") if (typeof options === "string" && color == null) { return { type: options }; } const out = Object.assign({}, (options && typeof options === "object") ? options : {}); if (color != null && !out.type) out.type = String(color); return out; }, // ========================================================= // [사용부] Toast UI // ========================================================= ensureToastRoot() { let root = d.getElementById("yytts-toast-root"); if (!root) { root = d.createElement("div"); root.id = "yytts-toast-root"; d.body.appendChild(root); } return root; }, showToast(toastId, cfg) { const root = this.ensureToastRoot(); const type = cfg.type || "info"; const sticky = !!cfg.sticky; const duration = Number(cfg.duration || 0); const msg = String(cfg.message || ""); const el = d.createElement("div"); el.className = `yytts-toast yytts-${this.mapType(type)}`; el.setAttribute("data-toast-id", toastId); const buttons = Array.isArray(cfg.buttons) && cfg.buttons.length ? cfg.buttons : [ { key: "ok", label: "확인", className: "yytts-btn yytts-btn-primary" }, { key: "close", label: "닫기", className: "yytts-btn" } ]; el.innerHTML = `
${this.escapeHtml(msg)}
${this.nowKST()}
${buttons.map(b => ` `).join("")}
${(!sticky && duration > 0) ? `
` : ""}
`; root.appendChild(el); this.toasts.set(toastId, el); let closed = false; let barTimer = null; const closeOnce = (reason) => { if (closed) return; closed = true; if (barTimer) clearInterval(barTimer); this.closeToast(toastId); if (typeof cfg.onClose === "function") { try { cfg.onClose(reason, { id: toastId, element: el }); } catch (e) {} } }; el.addEventListener("click", (e) => { const btn = e.target.closest("[data-yytts-action]"); if (!btn) return; const actionKey = btn.getAttribute("data-yytts-action") || ""; if (typeof cfg.onAction === "function") { try { cfg.onAction(actionKey, { id: toastId, element: el }); } catch (e) {} } // 기본동작: 어떤 버튼이든 닫기 closeOnce("action:" + actionKey); // sticky 토스트 클릭시 TTS 중단(형님 방향: toast+tts 세트) try { if (w.speechSynthesis) w.speechSynthesis.cancel(); } catch (e) {} }); // 자동 닫힘 if (!sticky && duration > 0) { const bar = el.querySelector(".yytts-toast-bar"); let width = 100; barTimer = setInterval(() => { width -= 100 / (duration / 100); if (bar) bar.style.width = Math.max(0, width) + "%"; if (width <= 0) { clearInterval(barTimer); barTimer = null; closeOnce("timeout"); } }, 100); } return toastId; }, updateToast(toastId, patch) { const el = this.toasts.get(toastId); if (!el || !patch) return false; if (patch.message != null) { const msgEl = el.querySelector(".yytts-toast-msg"); if (msgEl) msgEl.innerHTML = this.escapeHtml(String(patch.message)); } if (patch.type) { el.className = `yytts-toast yytts-${this.mapType(patch.type)}`; } return true; }, closeToast(toastId) { const el = this.toasts.get(toastId); if (!el) return false; el.classList.add("out"); setTimeout(() => { try { el.remove(); } catch (e) {} }, 180); this.toasts.delete(toastId); return true; }, closeAllToasts() { Array.from(this.toasts.keys()).forEach((id) => this.closeToast(id)); }, mapType(type) { const t = String(type || "info").toLowerCase(); if (t === "success" || t === "ok" || t === "green") return "success"; if (t === "warning" || t === "warn" || t === "yellow") return "warning"; if (t === "danger" || t === "error" || t === "red") return "danger"; if (t === "primary" || t === "blue") return "primary"; return "info"; }, // ========================================================= // [사용부] TTS // ========================================================= initVoiceList() { if (!w.speechSynthesis) return; try { this.voices = w.speechSynthesis.getVoices() || []; } catch (e) { this.voices = []; } const ko = this.voices.filter(v => String(v.lang || "").toLowerCase().indexOf("ko") >= 0); this.femaleVoices = ko.filter(v => /female|woman|여성/i.test(v.name)); this.maleVoices = ko.filter(v => /male|man|남성/i.test(v.name)); const fallback = ko[0] || this.voices[0] || null; this.preferredFemaleVoice = this.femaleVoices[0] || fallback; this.preferredMaleVoice = this.maleVoices[0] || fallback; }, pickVoice() { const s = this.settings || {}; if (s.voice === "male") return this.preferredMaleVoice || this.preferredFemaleVoice || null; return this.preferredFemaleVoice || this.preferredMaleVoice || null; }, speak(text) { const s = this.settings || {}; if (!w.speechSynthesis || !text) return false; try { w.speechSynthesis.cancel(); } catch (e) {} try { const u = new SpeechSynthesisUtterance(String(text)); u.lang = s.lang || "ko-KR"; u.volume = Number(s.volume == null ? 1 : s.volume); u.rate = Number(s.rate == null ? 1 : s.rate); u.pitch = Number(s.pitch == null ? 1 : s.pitch); const v = this.pickVoice(); if (v) u.voice = v; w.speechSynthesis.speak(u); return true; } catch (e) { return false; } }, stopTTS() { try { if (w.speechSynthesis) w.speechSynthesis.cancel(); } catch (e) {} }, sanitizeForTTS(text) { try { return String(text) .replace(/\p{Extended_Pictographic}+/gu, " ") .replace(/\s+/g, " ") .trim(); } catch (e) { return String(text) .replace(/[⏰⏳⌛✅❌⚠️🔊📌🟢🟡🔴⭐️✨🔥🚨]+/g, " ") .replace(/\s+/g, " ") .trim(); } }, // ========================================================= // [공통 유틸] // ========================================================= escapeHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }, nowKST() { try { return new Date().toLocaleString("ko-KR", { timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); } catch (e) { return new Date().toLocaleString(); } }, // ========================================================= // [로더부] 스타일 주입 // ========================================================= injectStyles() { if (d.getElementById("yytts-styles")) return; const style = d.createElement("style"); style.id = "yytts-styles"; style.textContent = ` #yytts-toast-root{ position:fixed; top:16px; right:16px; z-index:99999; display:flex; flex-direction:column; gap:10px; max-width:min(420px, calc(100vw - 24px)); } .yytts-toast{ animation: yyttsIn .18s ease; } .yytts-toast.out{ animation: yyttsOut .18s ease forwards; } .yytts-toast-card{ background:#fff; color:#222; border:1px solid #ddd; border-left:6px solid #999; border-radius:12px; box-shadow:0 10px 22px rgba(0,0,0,.18); overflow:hidden; } .yytts-primary .yytts-toast-card{ border-left-color:#0d6efd; } .yytts-success .yytts-toast-card{ border-left-color:#198754; } .yytts-warning .yytts-toast-card{ border-left-color:#ffc107; } .yytts-danger .yytts-toast-card{ border-left-color:#dc3545; } .yytts-info .yytts-toast-card{ border-left-color:#6c757d; } .yytts-toast-msg{ padding:12px 12px 4px; line-height:1.4; font-size:14px; } .yytts-toast-time{ padding:0 12px 8px; font-size:12px; color:#666; } .yytts-toast-actions{ display:flex; gap:8px; padding:0 12px 12px; } .yytts-toast-bar{ height:3px; width:100%; background:#0d6efd; } .yytts-btn{ border:1px solid #ccc; background:#fff; color:#333; border-radius:8px; padding:5px 10px; cursor:pointer; font-size:12px; } .yytts-btn:hover{ background:#f5f5f5; } .yytts-btn-primary{ background:#0d6efd; border-color:#0d6efd; color:#fff; } .yytts-btn-primary:hover{ filter:brightness(.95); } .yytts-card{ border:1px solid #ddd; border-radius:12px; background:#fff; color:#222; box-shadow:0 6px 16px rgba(0,0,0,.08); overflow:hidden; font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; } .yytts-head{ display:flex; justify-content:space-between; align-items:center; padding:10px 12px; border-bottom:1px solid #eee; gap:8px; } .yytts-title{ font-weight:700; font-size:14px; } .yytts-head-actions{ display:flex; gap:6px; } .yytts-body{ padding:12px; } .yytts-row{ display:flex; flex-wrap:wrap; gap:10px 14px; margin-bottom:12px; font-size:13px; } .yytts-grid{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; } .yytts-grid label{ display:flex; flex-direction:column; gap:4px; font-size:12px; color:#444; } .yytts-grid input, .yytts-grid select{ border:1px solid #ccc; border-radius:8px; padding:7px 8px; font-size:13px; } .yytts-help{ margin-top:10px; font-size:12px; color:#666; border-top:1px dashed #ddd; padding-top:8px; } @keyframes yyttsIn { from{ transform:translateY(-8px); opacity:0; } to{ transform:none; opacity:1; } } @keyframes yyttsOut { from{ transform:none; opacity:1; } to{ transform:translateY(-6px); opacity:0; } } @media (max-width: 700px) { #yytts-toast-root{ left:12px; right:12px; top:auto; bottom:12px; max-width:none; } .yytts-grid{ grid-template-columns:1fr; } } `; d.head.appendChild(style); } }; // 전역 등록 w.YYToastTTS = YYToastTTS; // 형님이 원하는 쉬운 선언형 호출 w.toast = function (message, options, color) { return YYToastTTS.toast(message, options, color); }; })(window, document);