diff --git a/README.md b/README.md index ee4319b..d3f35b1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ This repository follows DokuWiki's plugin conventions at the top level (e.g. `sy Reusable PHP code lives in `src/` and is loaded via `autoload.php`. When adding new internal classes under the `dokuwiki\plugin\luxtools\` namespace, place them in `src/.php`. +JavaScript is split into small modules under `js/` and registered via `action.php` so DokuWiki loads them in order. + ## Configuration diff --git a/action.php b/action.php new file mode 100644 index 0000000..bf18b06 --- /dev/null +++ b/action.php @@ -0,0 +1,43 @@ +register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addScripts'); + } + + /** + * Add plugin JavaScript files in a deterministic order. + * + * @param Event $event + * @param mixed $param + * @return void + */ + public function addScripts(Event $event, $param) + { + $base = DOKU_BASE . 'lib/plugins/luxtools/js/'; + $scripts = array( + 'lightbox.js', + 'gallery-thumbnails.js', + 'open-service.js', + 'scratchpads.js', + 'main.js' + ); + + foreach ($scripts as $script) { + $event->data['script'][] = array( + 'type' => 'text/javascript', + 'src' => $base . $script + ); + } + } +} diff --git a/js/gallery-thumbnails.js b/js/gallery-thumbnails.js new file mode 100644 index 0000000..6d2b3fa --- /dev/null +++ b/js/gallery-thumbnails.js @@ -0,0 +1,56 @@ +/* global window, document */ + +(function () { + 'use strict'; + + var Luxtools = window.Luxtools || (window.Luxtools = {}); + + // ============================================================ + // Gallery Thumbnails Module + // ============================================================ + Luxtools.GalleryThumbnails = (function () { + function loadThumb(img) { + var src = img.getAttribute('data-thumb-src') || ''; + if (!src) return; + if (img.getAttribute('data-thumb-loading') === '1') return; + img.setAttribute('data-thumb-loading', '1'); + + var pre = new window.Image(); + pre.onload = function () { + img.src = src; + img.removeAttribute('data-thumb-src'); + img.removeAttribute('data-thumb-loading'); + }; + pre.onerror = function () { + img.removeAttribute('data-thumb-loading'); + }; + pre.src = src; + } + + function init() { + var imgs = document.querySelectorAll('div.luxtools-gallery img[data-thumb-src]'); + if (!imgs || !imgs.length) return; + + if ('IntersectionObserver' in window) { + var io = new window.IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + loadThumb(entry.target); + io.unobserve(entry.target); + }); + }, { rootMargin: '200px' }); + + imgs.forEach(function (img) { io.observe(img); }); + } else { + // Fallback: load soon after initial render + window.setTimeout(function () { + imgs.forEach(loadThumb); + }, 0); + } + } + + return { + init: init + }; + })(); +})(); diff --git a/js/lightbox.js b/js/lightbox.js new file mode 100644 index 0000000..c5d6b7a --- /dev/null +++ b/js/lightbox.js @@ -0,0 +1,265 @@ +/* global window, document */ + +(function () { + 'use strict'; + + var Luxtools = window.Luxtools || (window.Luxtools = {}); + + // ============================================================ + // Lightbox Module + // ============================================================ + Luxtools.Lightbox = (function () { + var lb = null; + var img = null; + var cap = null; + var items = []; + var index = 0; + + // Zoom/pan state + var scale = 1; + var panX = 0; + var panY = 0; + var minScale = 1; + var maxScale = 5; + var isPanning = false; + var panStartX = 0; + var panStartY = 0; + + // History state + var pushedHistory = false; + var closingFromPopstate = false; + + function ensureElement() { + if (lb) return; + + lb = document.createElement('div'); + lb.className = 'luxtools-lightbox'; + lb.setAttribute('role', 'dialog'); + lb.setAttribute('aria-modal', 'true'); + lb.setAttribute('aria-hidden', 'true'); + + lb.innerHTML = + '
' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
'; + + document.body.appendChild(lb); + img = lb.querySelector('img.luxtools-lightbox-img'); + cap = lb.querySelector('.luxtools-lightbox-caption'); + + lb.addEventListener('click', onClick); + } + + function clampIndex(n) { + if (n < 0) return items.length - 1; + if (n >= items.length) return 0; + return n; + } + + function applyTransform() { + if (scale <= 1 && panX === 0 && panY === 0) { + img.style.transform = ''; + } else { + img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)'; + } + img.style.cursor = scale > 1 ? 'grab' : ''; + } + + function resetZoom() { + scale = 1; + panX = 0; + panY = 0; + applyTransform(); + } + + function render() { + var it = items[index]; + img.src = it.full; + img.setAttribute('data-luxtools-index', String(index)); + if (cap) cap.textContent = (it.name || '').trim(); + resetZoom(); + } + + function next() { + index = clampIndex(index + 1); + render(); + } + + function prev() { + index = clampIndex(index - 1); + render(); + } + + // Event handlers + function onWheel(e) { + e.preventDefault(); + var delta = e.deltaY > 0 ? -0.15 : 0.15; + scale = Math.max(minScale, Math.min(maxScale, scale + delta)); + if (scale <= 1) { panX = 0; panY = 0; } + applyTransform(); + } + + function onDblClick(e) { + e.preventDefault(); + if (scale > 1) { + scale = 1; + panX = 0; + panY = 0; + } else { + scale = 2.5; + } + applyTransform(); + } + + function onMouseDown(e) { + if (scale > 1 && e.button === 0) { + isPanning = true; + panStartX = e.clientX - panX * scale; + panStartY = e.clientY - panY * scale; + img.style.cursor = 'grabbing'; + e.preventDefault(); + } + } + + function onMouseMove(e) { + if (isPanning && scale > 1) { + panX = (e.clientX - panStartX) / scale; + panY = (e.clientY - panStartY) / scale; + applyTransform(); + img.style.cursor = 'grabbing'; + } + } + + function onMouseUp() { + isPanning = false; + img.style.cursor = scale > 1 ? 'grab' : ''; + } + + function onKeyDown(e) { + if (!lb || !lb.classList.contains('is-open')) return; + var key = e.key || ''; + if (key === 'Escape') { + e.preventDefault(); + close(); + } else if (key === 'ArrowRight') { + e.preventDefault(); + next(); + } else if (key === 'ArrowLeft') { + e.preventDefault(); + prev(); + } + } + + function onPopState() { + if (!lb || !lb.classList.contains('is-open')) return; + closingFromPopstate = true; + try { close(); } finally { closingFromPopstate = false; } + } + + function onClick(e) { + var t = e.target; + if (!t || !t.getAttribute) return; + var action = t.getAttribute('data-luxtools-action') || ''; + if (action === 'close') { e.preventDefault(); close(); return; } + if (action === 'next') { e.preventDefault(); next(); return; } + if (action === 'prev') { e.preventDefault(); prev(); return; } + + if (t.closest && t.closest('button.luxtools-lightbox-zone')) return; + if (t.closest && t.closest('img.luxtools-lightbox-img')) return; + e.preventDefault(); + close(); + } + + function attachListeners() { + document.addEventListener('keydown', onKeyDown, true); + window.addEventListener('popstate', onPopState, true); + img.addEventListener('wheel', onWheel, { passive: false }); + img.addEventListener('dblclick', onDblClick); + img.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function detachListeners() { + document.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('popstate', onPopState, true); + img.removeEventListener('wheel', onWheel); + img.removeEventListener('dblclick', onDblClick); + img.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + function open(galleryEl, startEl) { + var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]'); + items = []; + links.forEach(function (a) { + var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || ''; + var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || ''; + if (!full) return; + items.push({ el: a, full: full, name: name }); + }); + if (!items.length) return; + + index = 0; + for (var i = 0; i < items.length; i++) { + if (items[i].el === startEl) { index = i; break; } + } + + ensureElement(); + pushedHistory = false; + closingFromPopstate = false; + + lb.classList.add('is-open'); + lb.setAttribute('aria-hidden', 'false'); + try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {} + try { document.body.style.overflow = 'hidden'; } catch (e) {} + + attachListeners(); + + try { + if (window.history && window.history.pushState) { + window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href); + pushedHistory = true; + } + } catch (e) {} + + render(); + } + + function close() { + if (!lb) return; + + lb.classList.remove('is-open'); + lb.setAttribute('aria-hidden', 'true'); + try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {} + try { document.body.style.overflow = ''; } catch (e) {} + img.src = ''; + resetZoom(); + + detachListeners(); + + if (pushedHistory && !closingFromPopstate) { + try { + if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) { + window.history.back(); + } + } catch (e) {} + } + + items = []; + } + + return { + open: open, + close: close + }; + })(); +})(); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..9d8f4cd --- /dev/null +++ b/js/main.js @@ -0,0 +1,92 @@ +/* global window, document */ + +(function () { + 'use strict'; + + var Luxtools = window.Luxtools || (window.Luxtools = {}); + var Lightbox = Luxtools.Lightbox; + var OpenService = Luxtools.OpenService; + var GalleryThumbnails = Luxtools.GalleryThumbnails; + var Scratchpads = Luxtools.Scratchpads; + + // ============================================================ + // Click Handlers + // ============================================================ + function findOpenElement(target) { + var el = target; + while (el && el !== document) { + if (el.classList && el.classList.contains('luxtools-open')) return el; + el = el.parentNode; + } + return null; + } + + function findGalleryItem(target) { + var el = target; + while (el && el !== document) { + if (el.classList && el.classList.contains('luxtools-gallery-item')) return el; + el = el.parentNode; + } + return null; + } + + function onClick(event) { + // Image gallery lightbox: intercept clicks so we don't navigate away. + var galleryItem = findGalleryItem(event.target); + if (galleryItem && Lightbox && Lightbox.open) { + var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null; + if (gallery) { + event.preventDefault(); + Lightbox.open(gallery, galleryItem); + return; + } + } + + var el = findOpenElement(event.target); + if (!el) return; + + // {{open>...}} renders as a link; avoid jumping to '#'. + if (el.tagName && el.tagName.toLowerCase() === 'a') { + event.preventDefault(); + } + + var raw = el.getAttribute('data-path') || ''; + if (!raw) return; + if (!OpenService || !OpenService.openViaService) return; + + // Prefer local client service. + OpenService.openViaService(el, raw) + .catch(function (err) { + // If the browser blocks the request before it reaches localhost (mixed-content, + // extensions, stricter CORS handling), fall back to a no-CORS GET ping. + if (OpenService && OpenService.pingOpenViaImage) { + OpenService.pingOpenViaImage(el, raw); + } + + // Fallback to old behavior (often blocked in modern browsers). + var url = OpenService && OpenService.normalizeToFileUrl ? OpenService.normalizeToFileUrl(raw) : ''; + if (!url) return; + console.warn('Local client service failed, falling back to file:// navigation:', err); + try { + window.open(url, '_blank', 'noopener'); + } catch (e) { + try { + window.location.href = url; + } catch (e2) { + console.error('Failed to open file URL:', e2); + } + } + }); + } + + // ============================================================ + // Initialize + // ============================================================ + document.addEventListener('click', onClick, false); + document.addEventListener('DOMContentLoaded', function () { + if (GalleryThumbnails && GalleryThumbnails.init) GalleryThumbnails.init(); + }, false); + document.addEventListener('DOMContentLoaded', function () { + if (Scratchpads && Scratchpads.init) Scratchpads.init(); + }, false); +})(); diff --git a/js/open-service.js b/js/open-service.js new file mode 100644 index 0000000..493f6a3 --- /dev/null +++ b/js/open-service.js @@ -0,0 +1,94 @@ +/* global window */ + +(function () { + 'use strict'; + + var Luxtools = window.Luxtools || (window.Luxtools = {}); + + // ============================================================ + // Open Service Module (file:// links) + // ============================================================ + Luxtools.OpenService = (function () { + function getServiceUrl(el) { + var url = el.getAttribute('data-service-url') || ''; + url = (url || '').trim(); + if (!url) return ''; + // strip trailing slashes + return url.replace(/\/+$/, ''); + } + + function pingOpenViaImage(el, rawPath) { + var baseUrl = getServiceUrl(el); + if (!baseUrl) return; + var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath); + + // Fire-and-forget without CORS. + try { + var img = new window.Image(); + img.src = url; + } catch (e) { + // ignore + } + } + + function openViaService(el, rawPath) { + var baseUrl = getServiceUrl(el); + if (!baseUrl) return Promise.reject(new Error('No client service configured')); + + var headers = { + 'Content-Type': 'application/json' + }; + + return window.fetch(baseUrl + '/open', { + method: 'POST', + mode: 'cors', + credentials: 'omit', + headers: headers, + body: JSON.stringify({ path: rawPath }) + }).then(function (res) { + if (!res.ok) { + return res.json().catch(function () { return null; }).then(function (body) { + var msg = (body && body.message) ? body.message : ('HTTP ' + res.status); + throw new Error(msg); + }); + } + return res.json().catch(function () { return { ok: true }; }); + }); + } + + function normalizeToFileUrl(path) { + if (!path) return ''; + + // already a file URL + if (/^file:\/\//i.test(path)) return path; + + // UNC path: \\server\share\path + if (/^\\\\/.test(path)) { + var p = path.replace(/^\\\\/, ''); + p = p.replace(/\\/g, '/'); + return 'file://///' + p; + } + + // Windows drive: C:\path\to\file + if (/^[a-zA-Z]:\\/.test(path)) { + var drive = path[0].toUpperCase(); + var rest = path.slice(2).replace(/\\/g, '/'); + return 'file:///' + drive + ':' + rest; + } + + // POSIX absolute: /home/user/file + if (path[0] === '/') { + return 'file://' + path; + } + + // Fall back to using the provided string. + return path; + } + + return { + openViaService: openViaService, + pingOpenViaImage: pingOpenViaImage, + normalizeToFileUrl: normalizeToFileUrl + }; + })(); +})(); diff --git a/js/scratchpads.js b/js/scratchpads.js new file mode 100644 index 0000000..3cae3c3 --- /dev/null +++ b/js/scratchpads.js @@ -0,0 +1,187 @@ +/* global window, document */ + +(function () { + 'use strict'; + + var Luxtools = window.Luxtools || (window.Luxtools = {}); + + // ============================================================ + // Scratchpads Module + // ============================================================ + Luxtools.Scratchpads = (function () { + function setEditMode(root, isEditing) { + if (!root || !root.classList) return; + + var view = root.querySelector('.luxtools-scratchpad-view'); + var editor = root.querySelector('.luxtools-scratchpad-editor'); + + if (isEditing) { + root.classList.add('is-editing'); + if (view) view.hidden = true; + if (editor) editor.hidden = false; + } else { + root.classList.remove('is-editing'); + if (view) view.hidden = false; + if (editor) editor.hidden = true; + } + } + + function setStatus(root, msg) { + var el = root.querySelector('.luxtools-scratchpad-status'); + if (!el) return; + el.textContent = msg || ''; + } + + function getSectok(root) { + // Prefer a token embedded with the rendered scratchpad. + try { + if (root && root.getAttribute) { + var t = String(root.getAttribute('data-sectok') || '').trim(); + if (t) return t; + } + } catch (e) {} + + // Fall back to DokuWiki's global JSINFO. + try { + if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok); + } catch (e) {} + + // Last resort: find any security token input on the page. + try { + var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]'); + if (inp && inp.value) return String(inp.value); + } catch (e2) {} + + return ''; + } + + function loadPad(root) { + var endpoint = (root.getAttribute('data-endpoint') || '').trim(); + var pad = (root.getAttribute('data-pad') || '').trim(); + var pageId = (root.getAttribute('data-pageid') || '').trim(); + if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params')); + + var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId); + return window.fetch(url, { + method: 'GET', + credentials: 'same-origin' + }).then(function (res) { + return res.json().catch(function () { return null; }).then(function (body) { + if (!res.ok || !body || body.ok !== true) { + var msg = (body && body.error) ? body.error : ('HTTP ' + res.status); + throw new Error(msg); + } + return body.text || ''; + }); + }); + } + + function savePad(root, text) { + var endpoint = (root.getAttribute('data-endpoint') || '').trim(); + var pad = (root.getAttribute('data-pad') || '').trim(); + var pageId = (root.getAttribute('data-pageid') || '').trim(); + if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params')); + + var params = new window.URLSearchParams(); + params.set('cmd', 'save'); + params.set('pad', pad); + params.set('id', pageId); + params.set('text', text || ''); + params.set('sectok', getSectok(root)); + + return window.fetch(endpoint, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: params.toString() + }).then(function (res) { + return res.json().catch(function () { return null; }).then(function (body) { + if (!res.ok || !body || body.ok !== true) { + var msg = (body && body.error) ? body.error : ('HTTP ' + res.status); + throw new Error(msg); + } + return true; + }); + }); + } + + function openEditor(root) { + var editor = root.querySelector('.luxtools-scratchpad-editor'); + var textarea = root.querySelector('textarea.luxtools-scratchpad-text'); + if (!editor || !textarea) return; + + setEditMode(root, true); + setStatus(root, 'Loading…'); + textarea.disabled = true; + + loadPad(root).then(function (text) { + textarea.value = text; + textarea.disabled = false; + setStatus(root, ''); + textarea.focus(); + }).catch(function (e) { + textarea.disabled = false; + setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error')); + }); + } + + function closeEditor(root) { + var editor = root.querySelector('.luxtools-scratchpad-editor'); + if (!editor) return; + + setEditMode(root, false); + setStatus(root, ''); + } + + function onClick(e) { + var t = e.target; + if (!t) return; + + var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null; + if (edit) { + var root = edit.closest('div.luxtools-scratchpad'); + if (!root) return; + e.preventDefault(); + openEditor(root); + return; + } + + var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null; + if (save) { + var rootS = save.closest('div.luxtools-scratchpad'); + if (!rootS) return; + e.preventDefault(); + var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text'); + if (!textareaS) return; + textareaS.disabled = true; + setStatus(rootS, 'Saving…'); + savePad(rootS, textareaS.value).then(function () { + setStatus(rootS, 'Saved. Reloading…'); + try { window.location.reload(); } catch (err) {} + }).catch(function (err) { + textareaS.disabled = false; + setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error')); + }); + return; + } + + var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null; + if (cancel) { + var rootC = cancel.closest('div.luxtools-scratchpad'); + if (!rootC) return; + e.preventDefault(); + closeEditor(rootC); + } + } + + function init() { + var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]'); + if (!pads || !pads.length) return; + document.addEventListener('click', onClick, true); + } + + return { + init: init + }; + })(); +})(); diff --git a/script.js b/script.js index 67d6a97..d5021b4 100644 --- a/script.js +++ b/script.js @@ -1,653 +1 @@ -/* global window, document */ - -(function () { - 'use strict'; - - // ============================================================ - // Lightbox Module - // ============================================================ - var Lightbox = (function () { - var lb = null; - var img = null; - var cap = null; - var items = []; - var index = 0; - - // Zoom/pan state - var scale = 1; - var panX = 0; - var panY = 0; - var minScale = 1; - var maxScale = 5; - var isPanning = false; - var panStartX = 0; - var panStartY = 0; - - // History state - var pushedHistory = false; - var closingFromPopstate = false; - - function ensureElement() { - if (lb) return; - - lb = document.createElement('div'); - lb.className = 'luxtools-lightbox'; - lb.setAttribute('role', 'dialog'); - lb.setAttribute('aria-modal', 'true'); - lb.setAttribute('aria-hidden', 'true'); - - lb.innerHTML = - '
' + - '
' + - '' + - '' + - '' + - '
' + - '' + - '
' + - '
' + - '
'; - - document.body.appendChild(lb); - img = lb.querySelector('img.luxtools-lightbox-img'); - cap = lb.querySelector('.luxtools-lightbox-caption'); - - lb.addEventListener('click', onClick); - } - - function clampIndex(n) { - if (n < 0) return items.length - 1; - if (n >= items.length) return 0; - return n; - } - - function applyTransform() { - if (scale <= 1 && panX === 0 && panY === 0) { - img.style.transform = ''; - } else { - img.style.transform = 'scale(' + scale + ') translate(' + panX + 'px, ' + panY + 'px)'; - } - img.style.cursor = scale > 1 ? 'grab' : ''; - } - - function resetZoom() { - scale = 1; - panX = 0; - panY = 0; - applyTransform(); - } - - function render() { - var it = items[index]; - img.src = it.full; - img.setAttribute('data-luxtools-index', String(index)); - if (cap) cap.textContent = (it.name || '').trim(); - resetZoom(); - } - - function next() { - index = clampIndex(index + 1); - render(); - } - - function prev() { - index = clampIndex(index - 1); - render(); - } - - // Event handlers - function onWheel(e) { - e.preventDefault(); - var delta = e.deltaY > 0 ? -0.15 : 0.15; - scale = Math.max(minScale, Math.min(maxScale, scale + delta)); - if (scale <= 1) { panX = 0; panY = 0; } - applyTransform(); - } - - function onDblClick(e) { - e.preventDefault(); - if (scale > 1) { - scale = 1; - panX = 0; - panY = 0; - } else { - scale = 2.5; - } - applyTransform(); - } - - function onMouseDown(e) { - if (scale > 1 && e.button === 0) { - isPanning = true; - panStartX = e.clientX - panX * scale; - panStartY = e.clientY - panY * scale; - img.style.cursor = 'grabbing'; - e.preventDefault(); - } - } - - function onMouseMove(e) { - if (isPanning && scale > 1) { - panX = (e.clientX - panStartX) / scale; - panY = (e.clientY - panStartY) / scale; - applyTransform(); - img.style.cursor = 'grabbing'; - } - } - - function onMouseUp() { - isPanning = false; - img.style.cursor = scale > 1 ? 'grab' : ''; - } - - function onKeyDown(e) { - if (!lb || !lb.classList.contains('is-open')) return; - var key = e.key || ''; - if (key === 'Escape') { - e.preventDefault(); - close(); - } else if (key === 'ArrowRight') { - e.preventDefault(); - next(); - } else if (key === 'ArrowLeft') { - e.preventDefault(); - prev(); - } - } - - function onPopState() { - if (!lb || !lb.classList.contains('is-open')) return; - closingFromPopstate = true; - try { close(); } finally { closingFromPopstate = false; } - } - - function onClick(e) { - var t = e.target; - if (!t || !t.getAttribute) return; - var action = t.getAttribute('data-luxtools-action') || ''; - if (action === 'close') { e.preventDefault(); close(); return; } - if (action === 'next') { e.preventDefault(); next(); return; } - if (action === 'prev') { e.preventDefault(); prev(); return; } - - if (t.closest && t.closest('button.luxtools-lightbox-zone')) return; - if (t.closest && t.closest('img.luxtools-lightbox-img')) return; - e.preventDefault(); - close(); - } - - function attachListeners() { - document.addEventListener('keydown', onKeyDown, true); - window.addEventListener('popstate', onPopState, true); - img.addEventListener('wheel', onWheel, { passive: false }); - img.addEventListener('dblclick', onDblClick); - img.addEventListener('mousedown', onMouseDown); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - } - - function detachListeners() { - document.removeEventListener('keydown', onKeyDown, true); - window.removeEventListener('popstate', onPopState, true); - img.removeEventListener('wheel', onWheel); - img.removeEventListener('dblclick', onDblClick); - img.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - } - - function open(galleryEl, startEl) { - var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]'); - items = []; - links.forEach(function (a) { - var full = a.getAttribute('data-luxtools-full') || a.getAttribute('href') || ''; - var name = a.getAttribute('data-luxtools-name') || a.getAttribute('title') || ''; - if (!full) return; - items.push({ el: a, full: full, name: name }); - }); - if (!items.length) return; - - index = 0; - for (var i = 0; i < items.length; i++) { - if (items[i].el === startEl) { index = i; break; } - } - - ensureElement(); - pushedHistory = false; - closingFromPopstate = false; - - lb.classList.add('is-open'); - lb.setAttribute('aria-hidden', 'false'); - try { document.documentElement.classList.add('luxtools-noscroll'); } catch (e) {} - try { document.body.style.overflow = 'hidden'; } catch (e) {} - - attachListeners(); - - try { - if (window.history && window.history.pushState) { - window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href); - pushedHistory = true; - } - } catch (e) {} - - render(); - } - - function close() { - if (!lb) return; - - lb.classList.remove('is-open'); - lb.setAttribute('aria-hidden', 'true'); - try { document.documentElement.classList.remove('luxtools-noscroll'); } catch (e) {} - try { document.body.style.overflow = ''; } catch (e) {} - img.src = ''; - resetZoom(); - - detachListeners(); - - if (pushedHistory && !closingFromPopstate) { - try { - if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) { - window.history.back(); - } - } catch (e) {} - } - - items = []; - } - - return { - open: open, - close: close - }; - })(); - - // ============================================================ - // Gallery Thumbnails Module - // ============================================================ - var GalleryThumbnails = (function () { - function loadThumb(img) { - var src = img.getAttribute('data-thumb-src') || ''; - if (!src) return; - if (img.getAttribute('data-thumb-loading') === '1') return; - img.setAttribute('data-thumb-loading', '1'); - - var pre = new window.Image(); - pre.onload = function () { - img.src = src; - img.removeAttribute('data-thumb-src'); - img.removeAttribute('data-thumb-loading'); - }; - pre.onerror = function () { - img.removeAttribute('data-thumb-loading'); - }; - pre.src = src; - } - - function init() { - var imgs = document.querySelectorAll('div.luxtools-gallery img[data-thumb-src]'); - if (!imgs || !imgs.length) return; - - if ('IntersectionObserver' in window) { - var io = new window.IntersectionObserver(function (entries) { - entries.forEach(function (entry) { - if (!entry.isIntersecting) return; - loadThumb(entry.target); - io.unobserve(entry.target); - }); - }, { rootMargin: '200px' }); - - imgs.forEach(function (img) { io.observe(img); }); - } else { - // Fallback: load soon after initial render - window.setTimeout(function () { - imgs.forEach(loadThumb); - }, 0); - } - } - - return { - init: init - }; - })(); - - // ============================================================ - // Open Service Module (file:// links) - // ============================================================ - var OpenService = (function () { - function getServiceUrl(el) { - var url = el.getAttribute('data-service-url') || ''; - url = (url || '').trim(); - if (!url) return ''; - // strip trailing slashes - return url.replace(/\/+$/, ''); - } - - function pingOpenViaImage(el, rawPath) { - var baseUrl = getServiceUrl(el); - if (!baseUrl) return; - var url = baseUrl + '/open?path=' + encodeURIComponent(rawPath); - - // Fire-and-forget without CORS. - try { - var img = new window.Image(); - img.src = url; - } catch (e) { - // ignore - } - } - - function openViaService(el, rawPath) { - var baseUrl = getServiceUrl(el); - if (!baseUrl) return Promise.reject(new Error('No client service configured')); - - var headers = { - 'Content-Type': 'application/json' - }; - - return window.fetch(baseUrl + '/open', { - method: 'POST', - mode: 'cors', - credentials: 'omit', - headers: headers, - body: JSON.stringify({ path: rawPath }) - }).then(function (res) { - if (!res.ok) { - return res.json().catch(function () { return null; }).then(function (body) { - var msg = (body && body.message) ? body.message : ('HTTP ' + res.status); - throw new Error(msg); - }); - } - return res.json().catch(function () { return { ok: true }; }); - }); - } - - function normalizeToFileUrl(path) { - if (!path) return ''; - - // already a file URL - if (/^file:\/\//i.test(path)) return path; - - // UNC path: \\server\share\path - if (/^\\\\/.test(path)) { - var p = path.replace(/^\\\\/, ''); - p = p.replace(/\\/g, '/'); - return 'file://///' + p; - } - - // Windows drive: C:\path\to\file - if (/^[a-zA-Z]:\\/.test(path)) { - var drive = path[0].toUpperCase(); - var rest = path.slice(2).replace(/\\/g, '/'); - return 'file:///' + drive + ':' + rest; - } - - // POSIX absolute: /home/user/file - if (path[0] === '/') { - return 'file://' + path; - } - - // Fall back to using the provided string. - return path; - } - - return { - openViaService: openViaService, - pingOpenViaImage: pingOpenViaImage, - normalizeToFileUrl: normalizeToFileUrl - }; - })(); - - // ============================================================ - // Scratchpads Module - // ============================================================ - var Scratchpads = (function () { - function setEditMode(root, isEditing) { - if (!root || !root.classList) return; - - var view = root.querySelector('.luxtools-scratchpad-view'); - var editor = root.querySelector('.luxtools-scratchpad-editor'); - - if (isEditing) { - root.classList.add('is-editing'); - if (view) view.hidden = true; - if (editor) editor.hidden = false; - } else { - root.classList.remove('is-editing'); - if (view) view.hidden = false; - if (editor) editor.hidden = true; - } - } - - function setStatus(root, msg) { - var el = root.querySelector('.luxtools-scratchpad-status'); - if (!el) return; - el.textContent = msg || ''; - } - - function getSectok(root) { - // Prefer a token embedded with the rendered scratchpad. - try { - if (root && root.getAttribute) { - var t = String(root.getAttribute('data-sectok') || '').trim(); - if (t) return t; - } - } catch (e) {} - - // Fall back to DokuWiki's global JSINFO. - try { - if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok); - } catch (e) {} - - // Last resort: find any security token input on the page. - try { - var inp = document.querySelector('input[name="sectok"], input[name="securitytoken"]'); - if (inp && inp.value) return String(inp.value); - } catch (e2) {} - - return ''; - } - - function loadPad(root) { - var endpoint = (root.getAttribute('data-endpoint') || '').trim(); - var pad = (root.getAttribute('data-pad') || '').trim(); - var pageId = (root.getAttribute('data-pageid') || '').trim(); - if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params')); - - var url = endpoint + '?cmd=load&pad=' + encodeURIComponent(pad) + '&id=' + encodeURIComponent(pageId); - return window.fetch(url, { - method: 'GET', - credentials: 'same-origin' - }).then(function (res) { - return res.json().catch(function () { return null; }).then(function (body) { - if (!res.ok || !body || body.ok !== true) { - var msg = (body && body.error) ? body.error : ('HTTP ' + res.status); - throw new Error(msg); - } - return body.text || ''; - }); - }); - } - - function savePad(root, text) { - var endpoint = (root.getAttribute('data-endpoint') || '').trim(); - var pad = (root.getAttribute('data-pad') || '').trim(); - var pageId = (root.getAttribute('data-pageid') || '').trim(); - if (!endpoint || !pad || !pageId) return Promise.reject(new Error('missing params')); - - var params = new window.URLSearchParams(); - params.set('cmd', 'save'); - params.set('pad', pad); - params.set('id', pageId); - params.set('text', text || ''); - params.set('sectok', getSectok(root)); - - return window.fetch(endpoint, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, - body: params.toString() - }).then(function (res) { - return res.json().catch(function () { return null; }).then(function (body) { - if (!res.ok || !body || body.ok !== true) { - var msg = (body && body.error) ? body.error : ('HTTP ' + res.status); - throw new Error(msg); - } - return true; - }); - }); - } - - function openEditor(root) { - var editor = root.querySelector('.luxtools-scratchpad-editor'); - var textarea = root.querySelector('textarea.luxtools-scratchpad-text'); - if (!editor || !textarea) return; - - setEditMode(root, true); - setStatus(root, 'Loading…'); - textarea.disabled = true; - - loadPad(root).then(function (text) { - textarea.value = text; - textarea.disabled = false; - setStatus(root, ''); - textarea.focus(); - }).catch(function (e) { - textarea.disabled = false; - setStatus(root, 'Load failed: ' + (e && e.message ? e.message : 'error')); - }); - } - - function closeEditor(root) { - var editor = root.querySelector('.luxtools-scratchpad-editor'); - if (!editor) return; - - setEditMode(root, false); - setStatus(root, ''); - } - - function onClick(e) { - var t = e.target; - if (!t) return; - - var edit = t.closest ? t.closest('a.luxtools-scratchpad-edit') : null; - if (edit) { - var root = edit.closest('div.luxtools-scratchpad'); - if (!root) return; - e.preventDefault(); - openEditor(root); - return; - } - - var save = t.closest ? t.closest('button.luxtools-scratchpad-save') : null; - if (save) { - var rootS = save.closest('div.luxtools-scratchpad'); - if (!rootS) return; - e.preventDefault(); - var textareaS = rootS.querySelector('textarea.luxtools-scratchpad-text'); - if (!textareaS) return; - textareaS.disabled = true; - setStatus(rootS, 'Saving…'); - savePad(rootS, textareaS.value).then(function () { - setStatus(rootS, 'Saved. Reloading…'); - try { window.location.reload(); } catch (err) {} - }).catch(function (err) { - textareaS.disabled = false; - setStatus(rootS, 'Save failed: ' + (err && err.message ? err.message : 'error')); - }); - return; - } - - var cancel = t.closest ? t.closest('button.luxtools-scratchpad-cancel') : null; - if (cancel) { - var rootC = cancel.closest('div.luxtools-scratchpad'); - if (!rootC) return; - e.preventDefault(); - closeEditor(rootC); - } - } - - function init() { - var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]'); - if (!pads || !pads.length) return; - document.addEventListener('click', onClick, true); - } - - return { - init: init - }; - })(); - - // ============================================================ - // Click Handlers - // ============================================================ - function findOpenElement(target) { - var el = target; - while (el && el !== document) { - if (el.classList && el.classList.contains('luxtools-open')) return el; - el = el.parentNode; - } - return null; - } - - function findGalleryItem(target) { - var el = target; - while (el && el !== document) { - if (el.classList && el.classList.contains('luxtools-gallery-item')) return el; - el = el.parentNode; - } - return null; - } - - function onClick(event) { - // Image gallery lightbox: intercept clicks so we don't navigate away. - var galleryItem = findGalleryItem(event.target); - if (galleryItem) { - var gallery = galleryItem.closest ? galleryItem.closest('div.luxtools-gallery[data-luxtools-gallery="1"]') : null; - if (gallery) { - event.preventDefault(); - Lightbox.open(gallery, galleryItem); - return; - } - } - - var el = findOpenElement(event.target); - if (!el) return; - - // {{open>...}} renders as a link; avoid jumping to '#'. - if (el.tagName && el.tagName.toLowerCase() === 'a') { - event.preventDefault(); - } - - var raw = el.getAttribute('data-path') || ''; - if (!raw) return; - - // Prefer local client service. - OpenService.openViaService(el, raw) - .catch(function (err) { - // If the browser blocks the request before it reaches localhost (mixed-content, - // extensions, stricter CORS handling), fall back to a no-CORS GET ping. - OpenService.pingOpenViaImage(el, raw); - - // Fallback to old behavior (often blocked in modern browsers). - var url = OpenService.normalizeToFileUrl(raw); - if (!url) return; - console.warn('Local client service failed, falling back to file:// navigation:', err); - try { - window.open(url, '_blank', 'noopener'); - } catch (e) { - try { - window.location.href = url; - } catch (e2) { - console.error('Failed to open file URL:', e2); - } - } - }); - } - - // ============================================================ - // Initialize - // ============================================================ - document.addEventListener('click', onClick, false); - document.addEventListener('DOMContentLoaded', GalleryThumbnails.init, false); - document.addEventListener('DOMContentLoaded', Scratchpads.init, false); -})(); +/* Deprecated: JavaScript modules moved into the js/ folder. */