491 lines
16 KiB
JavaScript
491 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">' +
|
||
'<button type="button" class="luxtools-lightbox-btn luxtools-lightbox-close" data-luxtools-action="close" aria-label="Close">×</button>' +
|
||
'<button type="button" class="luxtools-lightbox-btn luxtools-lightbox-prev" data-luxtools-action="prev" aria-label="Previous">‹</button>' +
|
||
'<img class="luxtools-lightbox-img" alt="" />' +
|
||
'<button type="button" class="luxtools-lightbox-btn luxtools-lightbox-next" data-luxtools-action="next" aria-label="Next">›</button>' +
|
||
'<button type="button" class="luxtools-lightbox-btn luxtools-lightbox-opennew" data-luxtools-action="newtab" aria-label="Open in new tab">↗</button>' +
|
||
'<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 openInNewTab() {
|
||
var it = items[index];
|
||
if (!it || !it.full) return;
|
||
try {
|
||
window.open(it.full, '_blank', 'noopener');
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
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(); }
|
||
if (action === 'next') { e.preventDefault(); next(); }
|
||
if (action === 'prev') { e.preventDefault(); prev(); }
|
||
if (action === 'newtab') { e.preventDefault(); openInNewTab(); }
|
||
|
||
// Click outside the image closes (but don't interfere with controls).
|
||
if (t.closest && t.closest('button.luxtools-lightbox-btn')) 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 setStatus(root, msg) {
|
||
var el = root.querySelector('.luxtools-scratchpad-status');
|
||
if (!el) return;
|
||
el.textContent = msg || '';
|
||
}
|
||
|
||
function getSectok() {
|
||
try {
|
||
if (window.JSINFO && window.JSINFO.sectok) return String(window.JSINFO.sectok);
|
||
} catch (e) {}
|
||
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());
|
||
|
||
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;
|
||
|
||
editor.hidden = false;
|
||
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;
|
||
editor.hidden = true;
|
||
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);
|
||
})();
|