514 lines
16 KiB
JavaScript
514 lines
16 KiB
JavaScript
/* global window, document */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
function initGalleryThumbs() {
|
|
var imgs = document.querySelectorAll('div.luxtools-gallery img[data-thumb-src]');
|
|
if (!imgs || !imgs.length) return;
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function ensureLightbox() {
|
|
var existing = document.querySelector('.luxtools-lightbox');
|
|
if (existing) return existing;
|
|
|
|
var root = document.createElement('div');
|
|
root.className = 'luxtools-lightbox';
|
|
root.setAttribute('role', 'dialog');
|
|
root.setAttribute('aria-modal', 'true');
|
|
root.setAttribute('aria-hidden', 'true');
|
|
|
|
root.innerHTML =
|
|
'<div class="luxtools-lightbox-backdrop" data-luxtools-action="close"></div>' +
|
|
'<div class="luxtools-lightbox-stage">' +
|
|
'<div class="luxtools-lightbox-media">' +
|
|
'<img class="luxtools-lightbox-img" alt="" />' +
|
|
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-prev" data-luxtools-action="prev" aria-label="Previous"></button>' +
|
|
'<button type="button" class="luxtools-lightbox-zone luxtools-lightbox-zone-next" data-luxtools-action="next" aria-label="Next"></button>' +
|
|
'</div>' +
|
|
'<div class="luxtools-lightbox-caption"></div>' +
|
|
'</div>';
|
|
|
|
document.body.appendChild(root);
|
|
return root;
|
|
}
|
|
|
|
function getGalleryItems(galleryEl) {
|
|
var links = galleryEl.querySelectorAll('a.luxtools-gallery-item[data-luxtools-full]');
|
|
var 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 });
|
|
});
|
|
return items;
|
|
}
|
|
|
|
function openLightboxFor(galleryEl, startEl) {
|
|
var items = getGalleryItems(galleryEl);
|
|
if (!items.length) return;
|
|
|
|
var index = 0;
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].el === startEl) { index = i; break; }
|
|
}
|
|
|
|
var lb = ensureLightbox();
|
|
var img = lb.querySelector('img.luxtools-lightbox-img');
|
|
var cap = lb.querySelector('.luxtools-lightbox-caption');
|
|
|
|
var pushedHistory = false;
|
|
var closingFromPopstate = false;
|
|
|
|
function clamp(n) {
|
|
if (n < 0) return items.length - 1;
|
|
if (n >= items.length) return 0;
|
|
return n;
|
|
}
|
|
|
|
function render() {
|
|
var it = items[index];
|
|
img.src = it.full;
|
|
img.setAttribute('data-luxtools-index', String(index));
|
|
cap.textContent = (it.name || '').trim();
|
|
}
|
|
|
|
function close() {
|
|
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) {}
|
|
// Clear src to stop downloads on close.
|
|
img.src = '';
|
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
window.removeEventListener('popstate', onPopState, true);
|
|
|
|
// If we opened by pushing a state, pop it when closing via UI.
|
|
if (pushedHistory && !closingFromPopstate) {
|
|
try {
|
|
if (window.history && window.history.state && window.history.state.luxtoolsLightbox === 1) {
|
|
window.history.back();
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
function next() {
|
|
index = clamp(index + 1);
|
|
render();
|
|
}
|
|
|
|
function prev() {
|
|
index = clamp(index - 1);
|
|
render();
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (!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.classList.contains('is-open')) return;
|
|
closingFromPopstate = true;
|
|
try { close(); } finally { closingFromPopstate = false; }
|
|
}
|
|
|
|
lb.onclick = function (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; }
|
|
|
|
// Click outside the image closes (but don't interfere with controls).
|
|
if (t.closest && t.closest('button.luxtools-lightbox-zone')) return;
|
|
if (t.closest && t.closest('img.luxtools-lightbox-img')) return;
|
|
e.preventDefault();
|
|
close();
|
|
};
|
|
|
|
// Open
|
|
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) {}
|
|
document.addEventListener('keydown', onKeyDown, true);
|
|
window.addEventListener('popstate', onPopState, true);
|
|
|
|
// Allow closing via browser back button.
|
|
try {
|
|
if (window.history && window.history.pushState) {
|
|
window.history.pushState({ luxtoolsLightbox: 1 }, '', window.location.href);
|
|
pushedHistory = true;
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
render();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function initScratchpads() {
|
|
var pads = document.querySelectorAll('div.luxtools-scratchpad[data-luxtools-scratchpad="1"]');
|
|
if (!pads || !pads.length) return;
|
|
|
|
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, '');
|
|
}
|
|
|
|
document.addEventListener('click', function (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);
|
|
}
|
|
}, true);
|
|
}
|
|
|
|
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();
|
|
openLightboxFor(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.
|
|
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.
|
|
pingOpenViaImage(el, raw);
|
|
|
|
// Fallback to old behavior (often blocked in modern browsers).
|
|
var url = 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', onClick, false);
|
|
document.addEventListener('DOMContentLoaded', initGalleryThumbs, false);
|
|
document.addEventListener('DOMContentLoaded', initScratchpads, false);
|
|
})();
|