← All changes
|
includes/extensions/Docs_KB/assets/frontend.js
+321
-409
51.1.44
→
51.1.87
View file →
| @@ -1,520 +1,432 @@ | ||
| 1 | 1 | /** |
| 2 | - * Docs & Knowledge Base - Frontend JavaScript | |
| 3 | - * Live search, TOC generation, smooth scroll, analytics | |
| 2 | + * Docs & Knowledge Base v2 – Apple Liquid-Glass Frontend Scripts | |
| 4 | 3 | * |
| 4 | + * Modules: | |
| 5 | + * 1. DocsSearch — AJAX live search with debounce & keyboard nav | |
| 6 | + * 2. TableOfContents — auto-generated, hierarchical, sticky, scroll-spy | |
| 7 | + * 3. ScrollReveal — IntersectionObserver fade-in animations | |
| 8 | + * 4. Reactions — emoji reactions (happy / neutral / sad) | |
| 9 | + * 5. DarkMode — light / dark / auto from cookie / system | |
| 10 | + * 6. SocialShare — copy link, Twitter, Facebook, LinkedIn | |
| 11 | + * 7. CopyAnchor — heading anchor copy-to-clipboard | |
| 12 | + * | |
| 5 | 13 | * @package King_Addons |
| 6 | 14 | */ |
| 7 | 15 | |
| 8 | -(function() { | |
| 16 | +(function () { | |
| 9 | 17 | 'use strict'; |
| 10 | 18 | |
| 11 | - // Configuration | |
| 12 | - const CONFIG = { | |
| 13 | - searchDebounce: 300, | |
| 14 | - scrollOffset: 80, | |
| 15 | - stickyOffset: 100 | |
| 16 | - }; | |
| 19 | + /* ======================================== | |
| 20 | + HELPERS | |
| 21 | + ======================================== */ | |
| 22 | + const $ = (sel, ctx = document) => ctx.querySelector(sel); | |
| 23 | + const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; | |
| 17 | 24 | |
| 18 | - // API endpoints | |
| 19 | - const API = { | |
| 20 | - search: kngDocsKB?.restUrl + 'search' || '/wp-json/king-addons/v1/docs/search', | |
| 21 | - nonce: kngDocsKB?.nonce || '' | |
| 22 | - }; | |
| 25 | + function debounce(fn, ms = 300) { | |
| 26 | + let t; | |
| 27 | + return (...a) => { | |
| 28 | + clearTimeout(t); | |
| 29 | + t = setTimeout(() => fn.apply(this, a), ms); | |
| 30 | + }; | |
| 31 | + } | |
| 23 | 32 | |
| 24 | - /** | |
| 25 | - * Debounce helper | |
| 26 | - */ | |
| 27 | - function debounce(func, wait) { | |
| 28 | - let timeout; | |
| 29 | - return function executedFunction(...args) { | |
| 30 | - const later = () => { | |
| 31 | - clearTimeout(timeout); | |
| 32 | - func(...args); | |
| 33 | - }; | |
| 34 | - clearTimeout(timeout); | |
| 35 | - timeout = setTimeout(later, wait); | |
| 36 | - }; | |
| 33 | + function setCookie(n, v, days = 365) { | |
| 34 | + const d = new Date(); | |
| 35 | + d.setTime(d.getTime() + days * 864e5); | |
| 36 | + document.cookie = `${n}=${v};expires=${d.toUTCString()};path=/;SameSite=Lax`; | |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | - /** | |
| 40 | - * Live Search | |
| 41 | - */ | |
| 39 | + function getCookie(n) { | |
| 40 | + return (document.cookie.match(`(^|;)\\s*${n}=([^;]*)`) || [])[2] || ''; | |
| 41 | + } | |
| 42 | + | |
| 43 | + /* ======================================== | |
| 44 | + 1. DocsSearch | |
| 45 | + ======================================== */ | |
| 42 | 46 | class DocsSearch { |
| 43 | 47 | constructor(container) { |
| 44 | 48 | this.container = container; |
| 45 | - this.input = container.querySelector('.kng-docs-search-input'); | |
| 46 | - this.results = container.querySelector('.kng-docs-search-results'); | |
| 47 | - this.loader = container.querySelector('.kng-docs-search-loader'); | |
| 48 | - | |
| 49 | + this.input = $('.kng-docs-search-input', container); | |
| 50 | + this.results = $('.kng-docs-search-results', container); | |
| 51 | + this.loader = $('.kng-docs-search-loader', container); | |
| 52 | + this.shortcut = $('.kng-docs-search-shortcut', container); | |
| 49 | 53 | if (!this.input || !this.results) return; |
| 50 | - | |
| 51 | - this.minChars = parseInt(this.input.dataset.minChars) || 2; | |
| 52 | - this.categoryId = this.input.dataset.category || ''; | |
| 53 | - | |
| 54 | - this.init(); | |
| 54 | + | |
| 55 | + this.activeIdx = -1; | |
| 56 | + this._bind(); | |
| 55 | 57 | } |
| 56 | 58 | |
| 57 | - init() { | |
| 58 | - this.input.addEventListener('input', debounce(() => this.handleSearch(), CONFIG.searchDebounce)); | |
| 59 | - this.input.addEventListener('focus', () => this.showResults()); | |
| 60 | - | |
| 61 | - // Close on outside click | |
| 59 | + _bind() { | |
| 60 | + this.input.addEventListener('input', debounce(() => this._search(), 250)); | |
| 61 | + this.input.addEventListener('keydown', (e) => this._keydown(e)); | |
| 62 | + this.input.addEventListener('focus', () => { | |
| 63 | + if (this.input.value.trim().length >= 2 && this.results.childElementCount) { | |
| 64 | + this.results.style.display = 'block'; | |
| 65 | + } | |
| 66 | + }); | |
| 67 | + | |
| 62 | 68 | document.addEventListener('click', (e) => { |
| 63 | - if (!this.container.contains(e.target)) { | |
| 64 | - this.hideResults(); | |
| 69 | + if (!this.container.contains(e.target)) this._close(); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + /* ⌘K / Ctrl+K shortcut */ | |
| 73 | + document.addEventListener('keydown', (e) => { | |
| 74 | + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
| 75 | + e.preventDefault(); | |
| 76 | + this.input.focus(); | |
| 65 | 77 | } |
| 78 | + if (e.key === 'Escape') this._close(); | |
| 66 | 79 | }); |
| 67 | - | |
| 68 | - // Keyboard navigation | |
| 69 | - this.input.addEventListener('keydown', (e) => this.handleKeyboard(e)); | |
| 70 | 80 | } |
| 71 | 81 | |
| 72 | - async handleSearch() { | |
| 73 | - const query = this.input.value.trim(); | |
| 74 | - | |
| 75 | - if (query.length < this.minChars) { | |
| 76 | - this.hideResults(); | |
| 82 | + async _search() { | |
| 83 | + const q = this.input.value.trim(); | |
| 84 | + if (q.length < 2) { | |
| 85 | + this._close(); | |
| 77 | 86 | return; |
| 78 | 87 | } |
| 79 | 88 | |
| 80 | - this.showLoader(); | |
| 89 | + this._showLoader(true); | |
| 81 | 90 | |
| 82 | 91 | try { |
| 83 | - let url = `${API.search}?s=${encodeURIComponent(query)}&per_page=10`; | |
| 84 | - if (this.categoryId) { | |
| 85 | - url += `&category=${this.categoryId}`; | |
| 86 | - } | |
| 87 | - | |
| 88 | - const response = await fetch(url, { | |
| 89 | - headers: { | |
| 90 | - 'X-WP-Nonce': API.nonce | |
| 91 | - } | |
| 92 | - }); | |
| 93 | - | |
| 94 | - const data = await response.json(); | |
| 95 | - this.renderResults(data); | |
| 96 | - } catch (error) { | |
| 97 | - console.error('Search error:', error); | |
| 98 | - this.renderEmpty(); | |
| 92 | + const r = await fetch( | |
| 93 | + `${kingDocsKB.restUrl}search?q=${encodeURIComponent(q)}`, | |
| 94 | + { headers: { 'X-WP-Nonce': kingDocsKB.nonce } } | |
| 95 | + ); | |
| 96 | + const data = await r.json(); | |
| 97 | + this._render(data.results || [], q); | |
| 98 | + } catch (err) { | |
| 99 | + // console.error('[KNG Docs] search error', err); | |
| 100 | + } finally { | |
| 101 | + this._showLoader(false); | |
| 99 | 102 | } |
| 100 | - | |
| 101 | - this.hideLoader(); | |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | - renderResults(results) { | |
| 105 | - if (!results || results.length === 0) { | |
| 106 | - this.renderEmpty(); | |
| 105 | + _render(items, query) { | |
| 106 | + this.activeIdx = -1; | |
| 107 | + if (!items.length) { | |
| 108 | + this.results.innerHTML = `<div class="kng-docs-search-empty">${kingDocsKB.i18n?.noResults || 'No articles found.'}</div>`; | |
| 109 | + this.results.style.display = 'block'; | |
| 107 | 110 | return; |
| 108 | 111 | } |
| 109 | 112 | |
| 110 | - const html = results.map(item => ` | |
| 111 | - <a href="${item.url}" class="kng-docs-search-result"> | |
| 112 | - <div class="kng-docs-search-result-title">${item.title}</div> | |
| 113 | - ${item.category ? `<div class="kng-docs-search-result-category">${item.category}</div>` : ''} | |
| 114 | - </a> | |
| 115 | - `).join(''); | |
| 113 | + const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); | |
| 114 | + this.results.innerHTML = items.map(i => { | |
| 115 | + const title = i.title.replace(re, '<mark>$1</mark>'); | |
| 116 | + return `<a class="kng-docs-search-result" href="${i.url}"> | |
| 117 | + <span class="kng-docs-search-result-title">${title}</span> | |
| 118 | + <span class="kng-docs-search-result-meta"> | |
| 119 | + ${i.category ? `<span>${i.category}</span>` : ''} | |
| 120 | + ${i.category && i.reading_time ? `<span class="kng-docs-dot"></span>` : ''} | |
| 121 | + ${i.reading_time ? `<span>${i.reading_time}</span>` : ''} | |
| 122 | + </span> | |
| 123 | + </a>`; | |
| 124 | + }).join(''); | |
| 116 | 125 | |
| 117 | - this.results.innerHTML = html; | |
| 118 | - this.showResults(); | |
| 126 | + this.results.style.display = 'block'; | |
| 119 | 127 | } |
| 120 | 128 | |
| 121 | - renderEmpty() { | |
| 122 | - this.results.innerHTML = ` | |
| 123 | - <div class="kng-docs-search-empty"> | |
| 124 | - ${kngDocsKB?.i18n?.noResults || 'No results found'} | |
| 125 | - </div> | |
| 126 | - `; | |
| 127 | - this.showResults(); | |
| 128 | - } | |
| 129 | + _keydown(e) { | |
| 130 | + const items = $$('.kng-docs-search-result', this.results); | |
| 131 | + if (!items.length) return; | |
| 129 | 132 | |
| 130 | - showResults() { | |
| 131 | - if (this.input.value.trim().length >= this.minChars) { | |
| 132 | - this.results.style.display = 'block'; | |
| 133 | + if (e.key === 'ArrowDown') { | |
| 134 | + e.preventDefault(); | |
| 135 | + this.activeIdx = Math.min(this.activeIdx + 1, items.length - 1); | |
| 136 | + this._highlight(items); | |
| 137 | + } else if (e.key === 'ArrowUp') { | |
| 138 | + e.preventDefault(); | |
| 139 | + this.activeIdx = Math.max(this.activeIdx - 1, 0); | |
| 140 | + this._highlight(items); | |
| 141 | + } else if (e.key === 'Enter' && this.activeIdx >= 0) { | |
| 142 | + e.preventDefault(); | |
| 143 | + items[this.activeIdx].click(); | |
| 133 | 144 | } |
| 134 | 145 | } |
| 135 | 146 | |
| 136 | - hideResults() { | |
| 137 | - this.results.style.display = 'none'; | |
| 147 | + _highlight(items) { | |
| 148 | + items.forEach((el, i) => el.classList.toggle('is-focused', i === this.activeIdx)); | |
| 149 | + if (this.activeIdx >= 0) items[this.activeIdx].scrollIntoView({ block: 'nearest' }); | |
| 138 | 150 | } |
| 139 | 151 | |
| 140 | - showLoader() { | |
| 141 | - if (this.loader) { | |
| 142 | - this.loader.style.display = 'flex'; | |
| 143 | - } | |
| 152 | + _showLoader(show) { | |
| 153 | + if (this.loader) this.loader.style.display = show ? 'block' : 'none'; | |
| 154 | + if (this.shortcut) this.shortcut.style.display = show ? 'none' : ''; | |
| 144 | 155 | } |
| 145 | 156 | |
| 146 | - hideLoader() { | |
| 147 | - if (this.loader) { | |
| 148 | - this.loader.style.display = 'none'; | |
| 149 | - } | |
| 157 | + _close() { | |
| 158 | + this.results.style.display = 'none'; | |
| 159 | + this.activeIdx = -1; | |
| 150 | 160 | } |
| 151 | - | |
| 152 | - handleKeyboard(e) { | |
| 153 | - const items = this.results.querySelectorAll('.kng-docs-search-result'); | |
| 154 | - const activeItem = this.results.querySelector('.kng-docs-search-result:focus'); | |
| 155 | - const activeIndex = activeItem ? Array.from(items).indexOf(activeItem) : -1; | |
| 156 | - | |
| 157 | - switch (e.key) { | |
| 158 | - case 'ArrowDown': | |
| 159 | - e.preventDefault(); | |
| 160 | - if (activeIndex < items.length - 1) { | |
| 161 | - items[activeIndex + 1].focus(); | |
| 162 | - } else if (activeIndex === -1 && items.length > 0) { | |
| 163 | - items[0].focus(); | |
| 164 | - } | |
| 165 | - break; | |
| 166 | - | |
| 167 | - case 'ArrowUp': | |
| 168 | - e.preventDefault(); | |
| 169 | - if (activeIndex > 0) { | |
| 170 | - items[activeIndex - 1].focus(); | |
| 171 | - } else { | |
| 172 | - this.input.focus(); | |
| 173 | - } | |
| 174 | - break; | |
| 175 | - | |
| 176 | - case 'Escape': | |
| 177 | - this.hideResults(); | |
| 178 | - this.input.blur(); | |
| 179 | - break; | |
| 180 | - } | |
| 181 | - } | |
| 182 | 161 | } |
| 183 | 162 | |
| 184 | - /** | |
| 185 | - * Table of Contents Generator | |
| 186 | - */ | |
| 163 | + /* ======================================== | |
| 164 | + 2. TableOfContents — auto-generated | |
| 165 | + ======================================== */ | |
| 187 | 166 | class TableOfContents { |
| 188 | - constructor(contentEl, tocEl, floatingTocEl = null) { | |
| 167 | + constructor(contentEl, tocListEl, floatingListEl) { | |
| 189 | 168 | this.content = contentEl; |
| 190 | - this.toc = tocEl; | |
| 191 | - this.floatingToc = floatingTocEl; | |
| 192 | - | |
| 169 | + this.tocList = tocListEl; | |
| 170 | + this.floatingList = floatingListEl; | |
| 193 | 171 | if (!this.content) return; |
| 194 | - | |
| 195 | - this.headings = this.content.dataset.tocHeadings || 'h2,h3'; | |
| 196 | - this.init(); | |
| 197 | - } | |
| 198 | 172 | |
| 199 | - init() { | |
| 200 | - const elements = this.content.querySelectorAll(this.headings); | |
| 201 | - | |
| 202 | - if (elements.length === 0) { | |
| 203 | - if (this.toc) this.toc.style.display = 'none'; | |
| 204 | - return; | |
| 205 | - } | |
| 173 | + this.headings = $$('h2, h3, h4', this.content); | |
| 174 | + if (!this.headings.length) return; | |
| 206 | 175 | |
| 207 | - const tocItems = []; | |
| 176 | + this._build(); | |
| 177 | + this._spy(); | |
| 178 | + } | |
| 208 | 179 | |
| 209 | - elements.forEach((el, index) => { | |
| 210 | - const id = `toc-${index}`; | |
| 211 | - el.id = id; | |
| 212 | - | |
| 213 | - const level = el.tagName.toLowerCase(); | |
| 214 | - const text = el.textContent; | |
| 180 | + _build() { | |
| 181 | + const html = this.headings.map((h, i) => { | |
| 182 | + if (!h.id) h.id = `kng-heading-${i}`; | |
| 183 | + const tag = h.tagName.toLowerCase(); | |
| 184 | + return `<li class="kng-docs-toc-item kng-docs-toc-${tag}" data-id="${h.id}"> | |
| 185 | + <a href="#${h.id}">${h.textContent}</a> | |
| 186 | + </li>`; | |
| 187 | + }).join(''); | |
| 215 | 188 | |
| 216 | - tocItems.push({ | |
| 217 | - id, | |
| 218 | - text, | |
| 219 | - level | |
| 220 | - }); | |
| 221 | - }); | |
| 189 | + if (this.tocList) this.tocList.innerHTML = html; | |
| 190 | + if (this.floatingList) this.floatingList.innerHTML = html; | |
| 191 | + } | |
| 222 | 192 | |
| 223 | - const html = this.generateHTML(tocItems); | |
| 193 | + _spy() { | |
| 194 | + if (!('IntersectionObserver' in window)) return; | |
| 224 | 195 | |
| 225 | - if (this.toc) { | |
| 226 | - const list = this.toc.querySelector('.kng-docs-toc-list'); | |
| 227 | - if (list) { | |
| 228 | - list.innerHTML = html; | |
| 229 | - this.toc.style.display = ''; | |
| 230 | - } | |
| 231 | - } | |
| 196 | + const items = [ | |
| 197 | + ...(this.tocList ? $$('.kng-docs-toc-item', this.tocList) : []), | |
| 198 | + ...(this.floatingList ? $$('.kng-docs-toc-item', this.floatingList) : []), | |
| 199 | + ]; | |
| 232 | 200 | |
| 233 | - if (this.floatingToc) { | |
| 234 | - this.floatingToc.innerHTML = html; | |
| 235 | - } | |
| 236 | - | |
| 237 | - // Initialize scroll spy | |
| 238 | - this.initScrollSpy(tocItems); | |
| 239 | - | |
| 240 | - // Smooth scroll | |
| 241 | - this.initSmoothScroll(); | |
| 242 | - } | |
| 243 | - | |
| 244 | - generateHTML(items) { | |
| 245 | - return items.map(item => ` | |
| 246 | - <li class="kng-docs-toc-item kng-docs-toc-${item.level}"> | |
| 247 | - <a href="#${item.id}">${item.text}</a> | |
| 248 | - </li> | |
| 249 | - `).join(''); | |
| 250 | - } | |
| 251 | - | |
| 252 | - initScrollSpy(items) { | |
| 253 | - const observer = new IntersectionObserver((entries) => { | |
| 254 | - entries.forEach(entry => { | |
| 255 | - const link = document.querySelector(`.kng-docs-toc-item a[href="#${entry.target.id}"]`); | |
| 256 | - if (link) { | |
| 257 | - if (entry.isIntersecting) { | |
| 258 | - // Remove active from all | |
| 259 | - document.querySelectorAll('.kng-docs-toc-item').forEach(item => { | |
| 260 | - item.classList.remove('is-active'); | |
| 261 | - }); | |
| 262 | - link.parentElement.classList.add('is-active'); | |
| 263 | - } | |
| 201 | + const io = new IntersectionObserver((entries) => { | |
| 202 | + entries.forEach(e => { | |
| 203 | + if (e.isIntersecting) { | |
| 204 | + const id = e.target.id; | |
| 205 | + items.forEach(li => li.classList.toggle('is-active', li.dataset.id === id)); | |
| 264 | 206 | } |
| 265 | 207 | }); |
| 266 | - }, { | |
| 267 | - rootMargin: '-100px 0px -66%', | |
| 268 | - threshold: 0 | |
| 269 | - }); | |
| 208 | + }, { rootMargin: '-80px 0px -70% 0px', threshold: 0 }); | |
| 270 | 209 | |
| 271 | - items.forEach(item => { | |
| 272 | - const el = document.getElementById(item.id); | |
| 273 | - if (el) observer.observe(el); | |
| 274 | - }); | |
| 210 | + this.headings.forEach(h => io.observe(h)); | |
| 275 | 211 | } |
| 212 | + } | |
| 276 | 213 | |
| 277 | - initSmoothScroll() { | |
| 278 | - document.querySelectorAll('.kng-docs-toc-list a').forEach(link => { | |
| 279 | - link.addEventListener('click', (e) => { | |
| 280 | - e.preventDefault(); | |
| 281 | - const targetId = link.getAttribute('href').slice(1); | |
| 282 | - const target = document.getElementById(targetId); | |
| 283 | - | |
| 284 | - if (target) { | |
| 285 | - const offsetTop = target.getBoundingClientRect().top + window.pageYOffset - CONFIG.scrollOffset; | |
| 286 | - window.scrollTo({ | |
| 287 | - top: offsetTop, | |
| 288 | - behavior: 'smooth' | |
| 289 | - }); | |
| 214 | + /* ======================================== | |
| 215 | + 3. ScrollReveal | |
| 216 | + ======================================== */ | |
| 217 | + class ScrollReveal { | |
| 218 | + constructor() { | |
| 219 | + if (!('IntersectionObserver' in window)) { | |
| 220 | + $$('.kng-docs-reveal, .kng-docs-reveal-stagger').forEach(el => el.classList.add('is-visible')); | |
| 221 | + return; | |
| 222 | + } | |
| 290 | 223 | |
| 291 | - // Update URL without scrolling | |
| 292 | - history.pushState(null, '', link.getAttribute('href')); | |
| 224 | + const io = new IntersectionObserver((entries) => { | |
| 225 | + entries.forEach(e => { | |
| 226 | + if (e.isIntersecting) { | |
| 227 | + e.target.classList.add('is-visible'); | |
| 228 | + io.unobserve(e.target); | |
| 293 | 229 | } |
| 294 | 230 | }); |
| 295 | - }); | |
| 231 | + }, { threshold: 0.08, rootMargin: '0px 0px -40px 0px' }); | |
| 232 | + | |
| 233 | + $$('.kng-docs-reveal, .kng-docs-reveal-stagger').forEach(el => io.observe(el)); | |
| 296 | 234 | } |
| 297 | 235 | } |
| 298 | 236 | |
| 299 | - /** | |
| 300 | - * Article Feedback | |
| 301 | - */ | |
| 302 | - class ArticleFeedback { | |
| 237 | + /* ======================================== | |
| 238 | + 4. Reactions | |
| 239 | + ======================================== */ | |
| 240 | + class Reactions { | |
| 303 | 241 | constructor(container) { |
| 304 | - this.container = container; | |
| 305 | - this.docId = container.dataset.docId; | |
| 306 | - this.buttons = container.querySelector('.kng-docs-feedback-buttons'); | |
| 307 | - this.thanks = container.querySelector('.kng-docs-feedback-thanks'); | |
| 308 | - this.question = container.querySelector('.kng-docs-feedback-question'); | |
| 309 | - | |
| 310 | - if (!this.buttons) return; | |
| 311 | - | |
| 312 | - this.init(); | |
| 313 | - } | |
| 242 | + this.el = container; | |
| 243 | + if (!this.el) return; | |
| 314 | 244 | |
| 315 | - init() { | |
| 316 | - const btns = this.container.querySelectorAll('.kng-docs-feedback-btn'); | |
| 317 | - | |
| 318 | - btns.forEach(btn => { | |
| 319 | - btn.addEventListener('click', () => this.submitFeedback(btn.dataset.value)); | |
| 320 | - }); | |
| 321 | - } | |
| 245 | + this.postId = this.el.dataset.postId; | |
| 246 | + this.buttons = $$('.kng-docs-reaction-btn', this.el); | |
| 247 | + this.thanks = $('.kng-docs-reactions-thanks', this.el); | |
| 322 | 248 | |
| 323 | - async submitFeedback(value) { | |
| 324 | - // Check if already submitted | |
| 325 | - if (localStorage.getItem(`kng_doc_feedback_${this.docId}`)) { | |
| 326 | - this.showThanks(); | |
| 249 | + const stored = getCookie(`kng_docs_reaction_${this.postId}`); | |
| 250 | + if (stored) { | |
| 251 | + this._showThanks(); | |
| 327 | 252 | return; |
| 328 | 253 | } |
| 329 | 254 | |
| 255 | + this.buttons.forEach(btn => btn.addEventListener('click', () => this._react(btn))); | |
| 256 | + } | |
| 257 | + | |
| 258 | + async _react(btn) { | |
| 259 | + const type = btn.dataset.reaction; | |
| 260 | + btn.classList.add('is-selected'); | |
| 261 | + | |
| 330 | 262 | try { |
| 331 | - const formData = new FormData(); | |
| 332 | - formData.append('action', 'king_addons_docs_feedback'); | |
| 333 | - formData.append('doc_id', this.docId); | |
| 334 | - formData.append('feedback', value); | |
| 335 | - formData.append('nonce', kngDocsKB?.feedbackNonce || ''); | |
| 336 | - | |
| 337 | - await fetch(kngDocsKB?.ajaxUrl || '/wp-admin/admin-ajax.php', { | |
| 263 | + await fetch(kingDocsKB.ajaxUrl, { | |
| 338 | 264 | method: 'POST', |
| 339 | - body: formData | |
| 265 | + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
| 266 | + body: new URLSearchParams({ | |
| 267 | + action: 'king_docs_reaction', | |
| 268 | + post_id: this.postId, | |
| 269 | + reaction: type, | |
| 270 | + nonce: kingDocsKB.reactionNonce, | |
| 271 | + }), | |
| 340 | 272 | }); |
| 273 | + } catch (err) { | |
| 274 | + // console.error('[KNG Docs] reaction error', err); | |
| 275 | + } | |
| 341 | 276 | |
| 342 | - // Store in localStorage to prevent duplicate submissions | |
| 343 | - localStorage.setItem(`kng_doc_feedback_${this.docId}`, value); | |
| 344 | - | |
| 345 | - this.showThanks(); | |
| 346 | - } catch (error) { | |
| 347 | - console.error('Feedback error:', error); | |
| 348 | - } | |
| 277 | + setCookie(`kng_docs_reaction_${this.postId}`, type, 30); | |
| 278 | + this._showThanks(); | |
| 349 | 279 | } |
| 350 | 280 | |
| 351 | - showThanks() { | |
| 352 | - if (this.buttons) this.buttons.style.display = 'none'; | |
| 353 | - if (this.question) this.question.style.display = 'none'; | |
| 354 | - if (this.thanks) this.thanks.style.display = 'flex'; | |
| 281 | + _showThanks() { | |
| 282 | + this.buttons.forEach(btn => btn.style.display = 'none'); | |
| 283 | + if (this.thanks) this.thanks.classList.add('is-visible'); | |
| 355 | 284 | } |
| 356 | 285 | } |
| 357 | 286 | |
| 358 | - /** | |
| 359 | - * Sticky Elements | |
| 360 | - */ | |
| 361 | - class StickyElement { | |
| 362 | - constructor(element) { | |
| 363 | - this.element = element; | |
| 364 | - this.isSticky = element.classList.contains('is-sticky'); | |
| 365 | - | |
| 366 | - if (!this.isSticky) return; | |
| 367 | - | |
| 368 | - this.init(); | |
| 287 | + /* ======================================== | |
| 288 | + 5. DarkMode | |
| 289 | + ======================================== */ | |
| 290 | + class DarkMode { | |
| 291 | + constructor() { | |
| 292 | + const page = $('.kng-docs-page'); | |
| 293 | + if (!page) return; | |
| 294 | + | |
| 295 | + this.page = page; | |
| 296 | + this.mode = page.dataset.kngThemeMode || 'auto'; /* light | dark | auto */ | |
| 297 | + this.toggle = $('.kng-docs-dark-toggle'); | |
| 298 | + | |
| 299 | + this._apply(); | |
| 300 | + | |
| 301 | + if (this.toggle) { | |
| 302 | + this.toggle.addEventListener('click', () => this._cycle()); | |
| 303 | + } | |
| 304 | + | |
| 305 | + if (this.mode === 'auto') { | |
| 306 | + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this._apply()); | |
| 307 | + } | |
| 369 | 308 | } |
| 370 | 309 | |
| 371 | - init() { | |
| 372 | - const inner = this.element.querySelector('.kng-docs-sidebar-inner, .kng-docs-toc-floating-inner'); | |
| 373 | - | |
| 374 | - if (inner) { | |
| 375 | - inner.style.position = 'sticky'; | |
| 376 | - inner.style.top = `${CONFIG.stickyOffset}px`; | |
| 310 | + _apply() { | |
| 311 | + if (this.mode === 'auto') { | |
| 312 | + const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 313 | + this.page.dataset.kngTheme = systemDark ? 'dark' : 'light'; | |
| 314 | + } else { | |
| 315 | + this.page.dataset.kngTheme = this.mode; | |
| 377 | 316 | } |
| 378 | 317 | } |
| 318 | + | |
| 319 | + _cycle() { | |
| 320 | + const order = ['light', 'dark', 'auto']; | |
| 321 | + const idx = order.indexOf(this.mode); | |
| 322 | + this.mode = order[(idx + 1) % order.length]; | |
| 323 | + this.page.dataset.kngThemeMode = this.mode; | |
| 324 | + this._apply(); | |
| 325 | + setCookie('kng_docs_theme', this.mode, 365); | |
| 326 | + } | |
| 379 | 327 | } |
| 380 | 328 | |
| 381 | - /** | |
| 382 | - * Copy Code Button | |
| 383 | - */ | |
| 384 | - class CopyCode { | |
| 329 | + /* ======================================== | |
| 330 | + 6. SocialShare | |
| 331 | + ======================================== */ | |
| 332 | + class SocialShare { | |
| 385 | 333 | constructor() { |
| 386 | - this.init(); | |
| 387 | - } | |
| 334 | + $$('.kng-docs-share-btn').forEach(btn => { | |
| 335 | + btn.addEventListener('click', (e) => { | |
| 336 | + e.preventDefault(); | |
| 337 | + const type = btn.dataset.share; | |
| 338 | + const url = encodeURIComponent(window.location.href); | |
| 339 | + const title = encodeURIComponent(document.title); | |
| 388 | 340 | |
| 389 | - init() { | |
| 390 | - const codeBlocks = document.querySelectorAll('.kng-docs-article-content pre'); | |
| 391 | - | |
| 392 | - codeBlocks.forEach(block => { | |
| 393 | - const button = document.createElement('button'); | |
| 394 | - button.className = 'kng-docs-copy-btn'; | |
| 395 | - button.innerHTML = ` | |
| 396 | - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| 397 | - <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | |
| 398 | - <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| 399 | - </svg> | |
| 400 | - `; | |
| 401 | - button.title = kngDocsKB?.i18n?.copy || 'Copy'; | |
| 402 | - | |
| 403 | - block.style.position = 'relative'; | |
| 404 | - block.appendChild(button); | |
| 405 | - | |
| 406 | - button.addEventListener('click', () => this.copyToClipboard(block, button)); | |
| 341 | + switch (type) { | |
| 342 | + case 'copy': | |
| 343 | + navigator.clipboard.writeText(window.location.href).then(() => { | |
| 344 | + this._tooltip(btn, 'Copied!'); | |
| 345 | + }); | |
| 346 | + break; | |
| 347 | + case 'twitter': | |
| 348 | + window.open(`https://twitter.com/intent/tweet?url=${url}&text=${title}`, '_blank', 'width=600,height=400'); | |
| 349 | + break; | |
| 350 | + case 'facebook': | |
| 351 | + window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400'); | |
| 352 | + break; | |
| 353 | + case 'linkedin': | |
| 354 | + window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400'); | |
| 355 | + break; | |
| 356 | + } | |
| 357 | + }); | |
| 407 | 358 | }); |
| 408 | 359 | } |
| 409 | 360 | |
| 410 | - async copyToClipboard(block, button) { | |
| 411 | - const code = block.querySelector('code') || block; | |
| 412 | - const text = code.textContent; | |
| 413 | - | |
| 414 | - try { | |
| 415 | - await navigator.clipboard.writeText(text); | |
| 416 | - button.classList.add('copied'); | |
| 417 | - button.innerHTML = ` | |
| 418 | - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| 419 | - <polyline points="20 6 9 17 4 12"/> | |
| 420 | - </svg> | |
| 421 | - `; | |
| 422 | - | |
| 423 | - setTimeout(() => { | |
| 424 | - button.classList.remove('copied'); | |
| 425 | - button.innerHTML = ` | |
| 426 | - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| 427 | - <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/> | |
| 428 | - <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/> | |
| 429 | - </svg> | |
| 430 | - `; | |
| 431 | - }, 2000); | |
| 432 | - } catch (error) { | |
| 433 | - console.error('Copy failed:', error); | |
| 361 | + _tooltip(el, text) { | |
| 362 | + let tip = document.querySelector('.kng-docs-tooltip'); | |
| 363 | + if (!tip) { | |
| 364 | + tip = document.createElement('div'); | |
| 365 | + tip.className = 'kng-docs-tooltip'; | |
| 366 | + document.body.appendChild(tip); | |
| 434 | 367 | } |
| 368 | + tip.textContent = text; | |
| 369 | + const rect = el.getBoundingClientRect(); | |
| 370 | + tip.style.left = `${rect.left + rect.width / 2 - 30}px`; | |
| 371 | + tip.style.top = `${rect.top - 36}px`; | |
| 372 | + tip.classList.add('is-visible'); | |
| 373 | + setTimeout(() => tip.classList.remove('is-visible'), 1200); | |
| 435 | 374 | } |
| 436 | 375 | } |
| 437 | 376 | |
| 438 | - /** | |
| 439 | - * Reading Progress | |
| 440 | - */ | |
| 441 | - class ReadingProgress { | |
| 442 | - constructor(article) { | |
| 443 | - this.article = article; | |
| 444 | - | |
| 445 | - if (!this.article) return; | |
| 446 | - | |
| 447 | - this.init(); | |
| 448 | - } | |
| 377 | + /* ======================================== | |
| 378 | + 7. CopyAnchor | |
| 379 | + ======================================== */ | |
| 380 | + class CopyAnchor { | |
| 381 | + constructor() { | |
| 382 | + const content = $('.kng-docs-article-content'); | |
| 383 | + if (!content) return; | |
| 449 | 384 | |
| 450 | - init() { | |
| 451 | - const progressBar = document.createElement('div'); | |
| 452 | - progressBar.className = 'kng-docs-reading-progress'; | |
| 453 | - progressBar.innerHTML = '<div class="kng-docs-reading-progress-bar"></div>'; | |
| 454 | - document.body.appendChild(progressBar); | |
| 385 | + $$('h2, h3, h4', content).forEach(h => { | |
| 386 | + if (!h.id) return; | |
| 387 | + const btn = document.createElement('button'); | |
| 388 | + btn.className = 'kng-docs-heading-anchor'; | |
| 389 | + btn.setAttribute('aria-label', 'Copy link'); | |
| 390 | + btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'; | |
| 391 | + btn.addEventListener('click', () => { | |
| 392 | + const url = `${window.location.origin}${window.location.pathname}#${h.id}`; | |
| 393 | + navigator.clipboard.writeText(url); | |
| 394 | + }); | |
| 455 | 395 | |
| 456 | - const bar = progressBar.querySelector('.kng-docs-reading-progress-bar'); | |
| 457 | - | |
| 458 | - window.addEventListener('scroll', () => { | |
| 459 | - const articleRect = this.article.getBoundingClientRect(); | |
| 460 | - const articleTop = articleRect.top + window.pageYOffset; | |
| 461 | - const articleHeight = this.article.offsetHeight; | |
| 462 | - const windowHeight = window.innerHeight; | |
| 463 | - const scrollTop = window.pageYOffset; | |
| 464 | - | |
| 465 | - const start = articleTop - windowHeight; | |
| 466 | - const end = articleTop + articleHeight; | |
| 467 | - const progress = (scrollTop - start) / (end - start); | |
| 468 | - | |
| 469 | - bar.style.width = `${Math.min(Math.max(progress * 100, 0), 100)}%`; | |
| 396 | + h.style.position = 'relative'; | |
| 397 | + h.appendChild(btn); | |
| 470 | 398 | }); |
| 471 | 399 | } |
| 472 | 400 | } |
| 473 | 401 | |
| 474 | - /** | |
| 475 | - * Initialize | |
| 476 | - */ | |
| 477 | - function init() { | |
| 478 | - // Search | |
| 479 | - document.querySelectorAll('.kng-docs-search').forEach(el => { | |
| 480 | - new DocsSearch(el); | |
| 481 | - }); | |
| 402 | + /* ======================================== | |
| 403 | + INIT | |
| 404 | + ======================================== */ | |
| 405 | + document.addEventListener('DOMContentLoaded', () => { | |
| 406 | + /* Search */ | |
| 407 | + const searchEl = $('.kng-docs-search'); | |
| 408 | + if (searchEl) new DocsSearch(searchEl); | |
| 482 | 409 | |
| 483 | - // Table of Contents | |
| 484 | - const content = document.querySelector('.kng-docs-article-content'); | |
| 485 | - const toc = document.getElementById('kng-docs-toc'); | |
| 486 | - const floatingToc = document.querySelector('.kng-docs-toc-floating-list'); | |
| 487 | - | |
| 488 | - if (content) { | |
| 489 | - new TableOfContents(content, toc, floatingToc); | |
| 490 | - } | |
| 410 | + /* TOC */ | |
| 411 | + const articleContent = $('.kng-docs-article-content'); | |
| 412 | + const tocList = $('.kng-docs-toc-list'); | |
| 413 | + const floatingList = $('.kng-docs-toc-floating-list'); | |
| 414 | + if (articleContent) new TableOfContents(articleContent, tocList, floatingList); | |
| 491 | 415 | |
| 492 | - // Feedback | |
| 493 | - document.querySelectorAll('.kng-docs-feedback').forEach(el => { | |
| 494 | - new ArticleFeedback(el); | |
| 495 | - }); | |
| 416 | + /* Scroll reveal */ | |
| 417 | + new ScrollReveal(); | |
| 496 | 418 | |
| 497 | - // Sticky elements | |
| 498 | - document.querySelectorAll('.kng-docs-sidebar, .kng-docs-toc-floating').forEach(el => { | |
| 499 | - new StickyElement(el); | |
| 500 | - }); | |
| 419 | + /* Reactions */ | |
| 420 | + const reactionsEl = $('.kng-docs-reactions'); | |
| 421 | + if (reactionsEl) new Reactions(reactionsEl); | |
| 501 | 422 | |
| 502 | - // Copy code buttons | |
| 503 | - if (document.querySelector('.kng-docs-article-content pre')) { | |
| 504 | - new CopyCode(); | |
| 505 | - } | |
| 423 | + /* Dark mode */ | |
| 424 | + new DarkMode(); | |
| 506 | 425 | |
| 507 | - // Reading progress (optional) | |
| 508 | - const article = document.querySelector('.kng-docs-article'); | |
| 509 | - if (article && kngDocsKB?.showReadingProgress) { | |
| 510 | - new ReadingProgress(article); | |
| 511 | - } | |
| 512 | - } | |
| 426 | + /* Social share */ | |
| 427 | + new SocialShare(); | |
| 513 | 428 | |
| 514 | - // Run on DOM ready | |
| 515 | - if (document.readyState === 'loading') { | |
| 516 | - document.addEventListener('DOMContentLoaded', init); | |
| 517 | - } else { | |
| 518 | - init(); | |
| 519 | - } | |
| 429 | + /* Copy anchor */ | |
| 430 | + new CopyAnchor(); | |
| 431 | + }); | |
| 520 | 432 | })(); |