354 lines
11 KiB
JavaScript
354 lines
11 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 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);
|
||
})();
|