Files
luxtools-plugin/script.js
2026-01-15 19:56:08 +01:00

630 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 =
'<div class="luxtools-lightbox-backdrop" data-luxtools-action="close"></div>' +
'<div class="luxtools-lightbox-stage">' +
'<button type="button" class="luxtools-lightbox-close" data-luxtools-action="close" aria-label="Close">×</button>' +
'<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 class="luxtools-lightbox-media">' +
'<img class="luxtools-lightbox-img" alt="" />' +
'</div>' +
'<div class="luxtools-lightbox-caption"></div>' +
'</div>';
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
// ============================================================
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);
}
}
// ============================================================
// Open Service (file:// links)
// ============================================================
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);
}
// ============================================================
// 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.
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);
}
}
});
}
// ============================================================
// Initialize
// ============================================================
document.addEventListener('click', onClick, false);
document.addEventListener('DOMContentLoaded', initGalleryThumbs, false);
document.addEventListener('DOMContentLoaded', initScratchpads, false);
})();