From fa6014fefa6028641e64325b5a8a2f45e970e06b Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 20:46:11 +0100 Subject: [PATCH 01/14] Export module for a website gallery with selected images This export module should replace the PhotoSwipe copy in darktable. See https://github.com/darktable-org/darktable/issues/16205 for more details. --- contrib/website_gallery_export.lua | 262 ++++++++++++++++++ data/website_gallery/fullscreen.js | 40 +++ data/website_gallery/gallery.css | 113 ++++++++ data/website_gallery/gallery.js | 113 ++++++++ data/website_gallery/index.html | 45 ++++ data/website_gallery/modal.css | 95 +++++++ data/website_gallery/modal.js | 409 +++++++++++++++++++++++++++++ 7 files changed, 1077 insertions(+) create mode 100644 contrib/website_gallery_export.lua create mode 100644 data/website_gallery/fullscreen.js create mode 100644 data/website_gallery/gallery.css create mode 100644 data/website_gallery/gallery.js create mode 100644 data/website_gallery/index.html create mode 100644 data/website_gallery/modal.css create mode 100644 data/website_gallery/modal.js diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua new file mode 100644 index 00000000..c691b8b5 --- /dev/null +++ b/contrib/website_gallery_export.lua @@ -0,0 +1,262 @@ +--[[Export module to create a web gallery from selected images + + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +]] + +--[[ + TODO: + - before PR: Zoom, export code from wpferguson, use share_dir + - Lua: remove images dir if already existent + - Lua: implement "supported" callback to limit export to suited file formats + - Lua: translations +]] + +local dt = require "darktable" +local df = require "lib/dtutils.file" + +local temp = dt.preferences.read('web_gallery', 'title', 'string') +if temp == nil then temp = 'Darktable gallery' end + +local title_widget = dt.new_widget("entry") +{ + text = temp +} + +local temp = dt.preferences.read('web_gallery', 'destination_dir', 'string') +if temp == nil then temp = '' end + +local dest_dir_widget = dt.new_widget("file_chooser_button") +{ + title = "select output folder", + tooltip = "select output folder", + value = temp, + is_directory = true, + changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end +} + +local gallery_widget = dt.new_widget("box") +{ + orientation=vertical, + dt.new_widget("label"){label = "gallery title"}, + title_widget, + dt.new_widget("label"){label = "destination directory"}, + dest_dir_widget +} + +local function get_file_name(file) + return file:match("[^/]*.$") +end + +function escape_js_string(str) + local replacements = { + ['\\'] = '\\\\', + ['"'] = '\\"', + ["'"] = "\\'", + ['\n'] = '\\n', + ['\r'] = '\\r', + ['\t'] = '\\t', + ['\b'] = '\\b', + ['\f'] = '\\f', + ['\v'] = '\\v' + } + return (str:gsub('[\\\"\n\r\t\b\f\v\']', replacements)) +end + +local function export_thumbnail(image, filename) + dt.print("export thumbnail image "..filename) + exporter = dt.new_format("jpeg") + exporter.quality = 90 + exporter.max_height = 512 + exporter.max_width = 512 + exporter:write_image(image, filename, true) +end + +local function write_image(image, dest_dir, filename) + df.file_move(filename, dest_dir.."/"..get_file_name(filename)) + export_thumbnail(image, dest_dir.."/thumb_"..get_file_name(filename)) +end + +function exiftool_get_image_dimensions(filename) + local handle = io.popen("exiftool " .. filename) + local result = handle:read("*a") + handle:close() + for line in result:gmatch("[^\r\n]+") do + local w = line:match("^Image Width%s*:%s*(%d+)") + if w then + width = tonumber(w) + end + local h = line:match("^Image Height%s*:%s*(%d+)") + if h then + height = tonumber(h) + end + end + if width and height then + return width, height + else + return nil, nil + end +end + +local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) + dest_dir = dest_dir.."/images" + local gallery_data = { name = escape_js_string(title) } + + local images = {} + local index = 1 + for i, image in pairs(images_ordered) do + local filename = images_table[image] + write_image(image, dest_dir, filename) + + if exiftool then + width, height = exiftool_get_image_dimensions(dest_dir.."/"..get_file_name(filename)) + else + width = sizes[index].width + height = sizes[index].height + end + + local entry = { filename = "images/"..get_file_name(escape_js_string(filename)), + width = width, height = height } + + images[index] = entry + index = index + 1 + end + + gallery_data.images = images + return gallery_data +end + +local function generate_javascript_gallery_object(gallery) + local js = 'const gallery_data = {\n' + js = js .. ' name: "' .. gallery.name .. '",\n' + js = js .. ' images: [\n' + + for i, img in ipairs(gallery.images) do + js = js .. string.format(' { filename: "%s",\n height: %d,\n width: %d }', img.filename, img.height, img.width) + if i < #gallery.images then + js = js .. ',\n' + else + js = js .. '\n' + end + end + + js = js .. ' ]\n};\n' + + return(js) +end + +local function write_javascript_file(gallery_table, dest_dir) + dt.print("write JavaScript file") + javascript_object = generate_javascript_gallery_object(gallery_table) + + local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') + if fileOut then + fileOut:write(javascript_object) + else + log.msg(log.error, errr) + end + fileOut:close() +end + +local function copy_static_files(dest_dir) + dt.print("copy static gallery files") + gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" + gfiles = { + "index.html", + "gallery.css", + "modal.css", + "modal.js", + "gallery.js", + "fullscreen.js" + } + + for _, file in ipairs(gfiles) do + df.file_copy(gfsrc.."/"..file, dest_dir.."/"..file) + end +end + +local function build_gallery(storage, images_table, extra_data) + local dest_dir = dest_dir_widget.value + df.mkdir(dest_dir) + df.mkdir(dest_dir.."/images") + + local images_ordered = extra_data["images"] -- process images in the correct order + local sizes = extra_data["sizes"] + local title = "Darktable export" + if title_widget.text ~= "" then + title = title_widget.text + end + local exiftool = df.check_if_bin_exists("exiftool"); + gallerydata = fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) + write_javascript_file(gallerydata, dest_dir) + copy_static_files(dest_dir) +end + +local script_data = {} + +script_data.metadata = { + name = "website gallery (new)", + purpose = "create a web gallery from exported images", + author = "Tino Mettler ", + help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/TODO" +} + +script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil +script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again +script_data.show = nil -- only required for libs since the destroy_method only hides them + +local function destroy() + dt.preferences.write('web_gallery', 'title', 'string', title_widget.text) + dt.destroy_storage("module_webgallery") +end +script_data.destroy = destroy + +local function show_status(storage, image, format, filename, + number, total, high_quality, extra_data) + dt.print(string.format("export image %i/%i", number, total)) + aspect = image.aspect_ratio + -- calculate the size of the exported image and store it in extra_data + -- to make it available in the finalize function + if image.final_height == 0 then + if aspect < 1 then + dimensions = { width = image.height, height = image.width } + else + dimensions = { width = image.width, height = image.height } + end + else + dimensions = { width = image.final_width, height = image.final_height } + end + if format.max_height > 0 and dimensions.height > format.max_height then + scale = format.max_height / dimensions.height + dimensions.height = math.floor(dimensions.height * scale + 0.5) + dimensions.width = math.floor(dimensions.width * scale + 0.5) + end + if format.max_width > 0 and dimensions.width > format.max_width then + scale = format.max_width / dimensions.width + dimensions.height = math.floor(dimensions.height * scale + 0.5) + dimensions.width = math.floor(dimensions.width * scale + 0.5) + end + extra_data["sizes"][number] = dimensions +end + +local function initialize(storage, img_format, images, high_quality, extra_data) + dt.preferences.write('web_gallery', 'title', 'string', title_widget.text) + extra_data["images"] = images -- needed, to preserve images order + extra_data["sizes"] = {}; +end + +dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, nil, initialize, gallery_widget) + +return script_data diff --git a/data/website_gallery/fullscreen.js b/data/website_gallery/fullscreen.js new file mode 100644 index 00000000..c14b88cd --- /dev/null +++ b/data/website_gallery/fullscreen.js @@ -0,0 +1,40 @@ +var isFullscreen = false; + +var toggleFullscreen = function (ele) { + return isFullscreen ? exitFullscreen(ele) : requestFullscreen(ele); +}; + +var requestFullscreen = function (ele) { + if(isFullscreen == true) return 0 ; + + isFullscreen = true; + if (ele.requestFullscreen) { + ele.requestFullscreen(); + } else if (ele.webkitRequestFullscreen) { + ele.webkitRequestFullscreen(); + } else if (ele.mozRequestFullScreen) { + ele.mozRequestFullScreen(); + } else if (ele.msRequestFullscreen) { + ele.msRequestFullscreen(); + } else { + console.log('Fullscreen API is not supported.'); + } +}; + +var exitFullscreen = function () { + if(isFullscreen == false) return 0; + + isFullscreen = false; + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else { + console.log('Fullscreen API is not supported.'); + } +}; + diff --git a/data/website_gallery/gallery.css b/data/website_gallery/gallery.css new file mode 100644 index 00000000..5d05a0e1 --- /dev/null +++ b/data/website_gallery/gallery.css @@ -0,0 +1,113 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + +body { + font-family: system-ui, Arial, Helvetica, sans-serif; + background-color: rgb(238, 240, 242); +} + +.heading h1 { + text-align: center; + background-color: #cdd; + color: black; + display: grid; +} + +.heading h1:after { + content: " "; + border-bottom: 1px solid #888888; + height: 1px; +} + +.gallery { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 3vw; + margin: 3vw auto; + padding: 1.5vw; +} + +.slider { + margin: 0; + padding: 0; +} + + +.gallery img { + cursor: pointer; + object-fit: contain; + background: none; + padding: 0; + border: 1px outset #737780; + +} + +.gallery img:hover { + transform: scale(1.08); +} + +.thumb { + object-fit: contain; + flex-shrink: 0; + min-width: 100%; + min-height: 100%; + max-width: 100%; + max-height: 100%; +} + +.thumb-box { + display: flex; + align-items: center; + object-fit: contain; + justify-content: center; + background: none; + box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6); +} + +.navgrid { + font-size: 3.5vh; + display: grid; + grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em; + grid-column-gap: 1vw; + background-color: black; +} + +.nav { + height: 5vh; + background-color: rgba(60,60,60, 0.4); + justify-content: center; + align-items: center; + display: flex; + z-index: 1001; + color: #ccc; +} + +.counter { + font-size: 2.5vh; +} + +.button { + cursor: pointer; +} + +.button:hover { + cursor: pointer; + color: white; + text-shadow: 0px 0px 10px #ccc; +} diff --git a/data/website_gallery/gallery.js b/data/website_gallery/gallery.js new file mode 100644 index 00000000..54b0c875 --- /dev/null +++ b/data/website_gallery/gallery.js @@ -0,0 +1,113 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + +var scrollPosX = 0; +var scrollPosY = 0; + +document.addEventListener('DOMContentLoaded', function () { + var imageCount = 0; + const gallery = document.getElementById('gallery'); + const viewer = document.getElementById('viewer'); + + function showModal(e) { + const thumbbox = e.target.parentElement; + const index = [...gallery.children].indexOf(thumbbox); + currentIndex = index; + scrollPosX = document.documentElement.scrollLeft; + scrollPosY = document.documentElement.scrollTop; + + gallery.style.display = 'none'; + document.getElementById('heading1').style.display = 'none'; + viewer.style.display = 'grid'; + loadSlides(); + updateCounter(currentIndex); + updateNavigationState(); + } + + + function closeModal() { + exitFullscreen(document.documentElement); + viewer.style.display = 'none'; + document.getElementById('heading1').style.display = 'grid'; + gallery.style.display = 'flex'; + document.documentElement.scrollTo({ + left: scrollPosX, + top: scrollPosY, + behavior: "instant", + }); + }; + + function createThumbnailElement(imageObj) { + const frame = document.createElement('div'); + frame.className = 'thumb-box'; + const framesize = 18; + + const width = parseInt(imageObj.width); + const height = parseInt(imageObj.height); + const aspect = height / width; + const sum = width + height; + const scalefactor = sum / (framesize * 2.0); + frame.style.width = (width / scalefactor) + 'vw'; + frame.style.height = (height / scalefactor) + 'vw'; + + const img = document.createElement('img'); + img.className = 'thumb'; + img.src = imageObj.filename.replace(/images\/(.*)$/i, 'images/thumb_$1'); + img.alt = imageObj.filename; + img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); }); + + frame.appendChild(img); + gallery.appendChild(frame); + } + + const images = gallery_data.images; + + const title = document.getElementById('gallery-title'); + const pageTitle = document.getElementById('page-title'); + if (gallery_data.name) { + title.textContent = gallery_data.name; + pageTitle.textContent = gallery_data.name; + } + + + document.getElementById('close').onclick = function (e) { + e.stopPropagation(); + closeModal(); + }; + + // Keyboard navigation using left/right arrow keys + document.onkeyup = function (e) { + e.stopPropagation(); + switch(e.key) { + case "Escape": + closeModal(); + break; + } + }; + + document.getElementById('fullscreen').onclick = function (e) { + e.stopPropagation(); + toggleFullscreen(document.documentElement); + }; + + // Populate thumbnail gallery + images.forEach(function (imageObj) { + createThumbnailElement(imageObj); + }); + + +}); diff --git a/data/website_gallery/index.html b/data/website_gallery/index.html new file mode 100644 index 00000000..e9ea6140 --- /dev/null +++ b/data/website_gallery/index.html @@ -0,0 +1,45 @@ + + + + + + + + + Image Gallery + + + + + +
+

Image Gallery

+
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/data/website_gallery/modal.css b/data/website_gallery/modal.css new file mode 100644 index 00000000..e7c620da --- /dev/null +++ b/data/website_gallery/modal.css @@ -0,0 +1,95 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; + background: #000; + display: flex; + flex-direction: column; +} + +.viewer { + position: relative; + width: 100vw; + height: 100vh; + touch-action: none; + overflow: hidden; + display: none; +} + +.slider { + position: relative; + width: 100vw; + height: 95vh; + touch-action: none; + overflow: hidden; +} + +.slide-container { + position: absolute; + top: 0; + left: 0; + width: 300%; + height: 100%; + display: flex; + transform: translateX(-33.333%); + transition: transform 0.3s ease-out; + will-change: transform; +} + +.slide { + width: 33.333%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: #000; + will-change: contents; + overflow: hidden; + position: relative; + padding: 0; + margin: 0; + touch-action: none; + object-fit: contain; +} + +.slide img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + padding: 0; + margin: 0; + display: block; + transform-origin: 0 0; + cursor: zoom-in; + object-fit: contain; +} + +.slide.zoomed img { + cursor: zoom-out; + max-width: none; + max-height: none; +} + +.slide img.zooming { + transition: transform 0.2s ease-out; +} + +.nav-bar { + width: 100vw; + height: 5vh; + background: #333; + color: #fff; + display: flex; + align-items: center; + padding-left: 20px; + box-sizing: border-box; + font-family: Arial, sans-serif; + font-size: 16px; +} diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js new file mode 100644 index 00000000..7258f185 --- /dev/null +++ b/data/website_gallery/modal.js @@ -0,0 +1,409 @@ + +// DOM Elements +const container = document.querySelector('.slide-container'); +const slides = { + prev: document.getElementById('prev'), + current: document.getElementById('current'), + next: document.getElementById('next') +}; +const prevArrow = document.getElementById('prevArrow'); +const nextArrow = document.getElementById('nextArrow'); + +// State management +let mouseTimer = null; +let startX = 0; +let startY = 0; +let isDragging = false; +let isZoomed = false; +let isPanning = false; +let hasPanned = false; +let translateX = 0; +let translateY = 0; +let currentScale = 1; +let minX = 0; +let minY = 0; +let containerRect, imgRect; +let img; + +let baseWidth, baseHeight; // Image dimensions before zoom +let baseOffsetX, baseOffsetY; // Image position offset before zoom (due to centering) + +let scaledWidth, scaledHeight; + +const images = gallery_data.images; + +function updateCounter(index) { + const counter = document.getElementById('counter'); + counter.textContent = (index + 1) + ' / ' + images.length; +} + +function updateBoundaries() { + containerRect = container.getBoundingClientRect(); + // Calculate the actual scaled dimensions based on the base (pre-zoom) size + scaledWidth = baseWidth * currentScale; + scaledHeight = baseHeight * currentScale; + console.log('Boundaries:', { + containerRect, + scaledWidth, + scaledHeight, + baseWidth, + baseHeight, + currentScale + }); +} + +function limitPanning(proposedX, proposedY) { + // With transform-origin: 0 0 and transform: translate(tx, ty) scale(s) + // + // Before zoom: + // - Image element positioned at (baseOffsetX, baseOffsetY) in container + // - Image size is (baseWidth, baseHeight) + // + // After transform is applied: + // - First, scale happens around origin (0,0) of the element: element becomes (baseWidth*s, baseHeight*s) + // - Then translate by (tx, ty) moves the whole element + // - Final position in viewport: element's top-left is at (baseOffsetX + tx, baseOffsetY + ty) + // - Element's bottom-right is at (baseOffsetX + tx + scaledWidth, baseOffsetY + ty + scaledHeight) + // + // We want the image content edges to stay within the container while allowing original borders: + // - Left constraint: baseOffsetX + tx >= baseOffsetX => tx >= 0 + // - Right constraint: baseOffsetX + tx + scaledWidth <= containerWidth - (containerWidth - baseOffsetX - baseWidth) + // baseOffsetX + tx + scaledWidth <= baseOffsetX + baseWidth + // tx <= baseWidth - scaledWidth + // - Top constraint: baseOffsetY + ty >= baseOffsetY => ty >= 0 + // - Bottom constraint: baseOffsetY + ty + scaledHeight <= baseOffsetY + baseHeight + // ty <= baseHeight - scaledHeight + + // Calculate limits + const maxX = 0; + const minX = baseWidth - scaledWidth; + + const maxY = 0; + const minY = baseHeight - scaledHeight; + + let constrainedX = proposedX; + let constrainedY = proposedY; + + // Apply constraints + if (scaledWidth > baseWidth) { + // Image wider than original - constrain panning + constrainedX = Math.max(minX, Math.min(maxX, proposedX)); + } else { + // Image narrower than original - center it + constrainedX = (baseWidth - scaledWidth) / 2; + } + + if (scaledHeight > baseHeight) { + // Image taller than original - constrain panning + constrainedY = Math.max(minY, Math.min(maxY, proposedY)); + } else { + // Image shorter than original - center it + constrainedY = (baseHeight - scaledHeight) / 2; + } + + console.log('limitPanning:', { + proposed: { x: proposedX, y: proposedY }, + constrained: { x: constrainedX, y: constrainedY }, + limits: { minX, maxX, minY, maxY }, + baseOffset: { x: baseOffsetX, y: baseOffsetY }, + baseSize: { w: baseWidth, h: baseHeight }, + scaledSize: { w: scaledWidth, h: scaledHeight } + }); + + return { + x: constrainedX, + y: constrainedY + }; +} + +function handleZoom(e) { + // If we were actually panning, don't zoom + if (hasPanned) { + isPanning = false; + hasPanned = false; + return; + } + + // If we're zoomed and haven't panned, zoom out + if (isZoomed) { + img.classList.add('zooming'); + translateX = 0; + translateY = 0; + currentScale = 1; + img.style.transform = 'none'; + container.classList.remove('zoomed'); + isZoomed = false; + updateNavigationState(); + + setTimeout(() => { + img.classList.remove('zooming'); + }, 200); + return; + } + + // Zoom in at clicked/tapped point + if (!isZoomed) { + const rect = img.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Store the base (pre-zoom) dimensions and offset + baseWidth = rect.width; + baseHeight = rect.height; + baseOffsetX = rect.left; + baseOffsetY = rect.top; + + // Calculate the scale for 1:1 pixel zoom + currentScale = img.naturalWidth / rect.width; + + // Calculate the translation needed to keep clicked point under cursor + // After scaling, the point at (x, y) will be at (x * currentScale, y * currentScale) + // We want it to remain at (e.clientX - rect.left, e.clientY - rect.top) + translateX = e.clientX - rect.left - (x * currentScale); + translateY = e.clientY - rect.top - (y * currentScale); + + updateBoundaries(); + const limited = limitPanning(translateX, translateY); + translateX = limited.x; + translateY = limited.y; + + img.classList.add('zooming'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + container.classList.add('zoomed'); + + setTimeout(() => { + img.classList.remove('zooming'); + }, 200); + } + isZoomed = true; + updateNavigationState(); +} + +function createImageElement(imageData) { + if (!imageData) return null; + const img = new Image(); + img.src = imageData.filename; + img.width = imageData.width; + img.height = imageData.height; + img.addEventListener('dragstart', (e) => e.preventDefault()); + return img; +} + +function loadSlides() { + slides.prev.innerHTML = ''; + slides.current.innerHTML = ''; + slides.next.innerHTML = ''; + + if (currentIndex > 0) { + const prevImg = createImageElement(images[currentIndex - 1]); + if (prevImg) slides.prev.appendChild(prevImg); + } + + const currentImg = createImageElement(images[currentIndex]); + if (currentImg) { + img = currentImg; + slides.current.appendChild(currentImg); + } + + if (currentIndex < images.length - 1) { + const nextImg = createImageElement(images[currentIndex + 1]); + if (nextImg) slides.next.appendChild(nextImg); + } + + updateCounter(currentIndex) + updateNavigationState(); +} + +function updateNavigationState() { + prevArrow.style.visibility = (currentIndex === 0 || isZoomed) ? 'hidden' : 'visible'; + nextArrow.style.visibility = (currentIndex === images.length - 1 || isZoomed) ? 'hidden' : 'visible'; +} + +async function showPreviousImage() { + if (currentIndex > 0 && !isZoomed) { + currentIndex--; + container.style.transition = 'transform 0.3s ease-out'; + container.style.transform = 'translateX(0%)'; + await waitForTransition(); + container.style.transition = 'none'; + container.style.transform = 'translateX(-33.333%)'; + loadSlides(); + } +} + +async function showNextImage() { + if (currentIndex < images.length - 1 && !isZoomed) { + currentIndex++; + container.style.transition = 'transform 0.3s ease-out'; + container.style.transform = 'translateX(-66.666%)'; + await waitForTransition(); + container.style.transition = 'none'; + container.style.transform = 'translateX(-33.333%)'; + loadSlides(); + } +} + +function handleTouchStart(e) { + if (isZoomed) return; + startX = e.touches[0].clientX; + isDragging = true; + container.style.transition = 'none'; +} + +function handleTouchMove(e) { + if (!isDragging || isZoomed) return; + + const currentX = e.touches[0].clientX; + const diff = currentX - startX; + const baseOffset = -33.333; + const percentMoved = (diff / window.innerWidth) * 33.333; + + container.style.transform = `translateX(${baseOffset + percentMoved}%)`; +} + +async function handleTouchEnd(e) { + if (!isDragging) + if(isZoomed) { + handleZoom(e); + return; + } + isDragging = false; + + const endX = e.changedTouches[0].clientX; + const diff = endX - startX; + const threshold = window.innerWidth * 0.2; + + container.style.transition = 'transform 0.3s ease-out'; + + if (diff > threshold && currentIndex > 0) { + await showPreviousImage(); + } else if (diff < -threshold && currentIndex < images.length - 1) { + await showNextImage(); + } else { + container.style.transform = 'translateX(-33.333%)'; + } +} + +function handleMouseMove() { + if (isZoomed) return; + + prevArrow.classList.add('visible'); + nextArrow.classList.add('visible'); + + if (mouseTimer) { + clearTimeout(mouseTimer); + } + + mouseTimer = setTimeout(() => { + prevArrow.classList.remove('visible'); + nextArrow.classList.remove('visible'); + }, 1000); +} + +function handleKeyDown(e) { + if (isZoomed) return; + + if (e.code === 'Space' || e.code === 'ArrowRight') { + e.preventDefault(); + showNextImage(); + } else if (e.code === 'Backspace' || e.code === 'ArrowLeft') { + e.preventDefault(); + showPreviousImage(); + } +} + +function waitForTransition() { + return new Promise(resolve => { + container.addEventListener('transitionend', resolve, { once: true }); + }); +} + +// Initialize +loadSlides(); + +// Navigation event listeners +container.addEventListener('touchstart', handleTouchStart); +container.addEventListener('touchmove', handleTouchMove); +container.addEventListener('touchend', handleTouchEnd); +container.addEventListener('click', handleZoom); + +// make nav arrows visible +document.addEventListener('mousemove', handleMouseMove); + +document.addEventListener('keydown', handleKeyDown); +prevArrow.addEventListener('click', showPreviousImage); +nextArrow.addEventListener('click', showNextImage); + +// Mouse panning event listeners +container.addEventListener('mousedown', function(e) { + if (isZoomed && e.target.tagName === 'IMG') { + isPanning = true; + hasPanned = false; + updateBoundaries(); + startX = e.clientX - translateX; + startY = e.clientY - translateY; + e.preventDefault(); + container.style.cursor = 'grabbing'; + } +}); + +window.addEventListener('mousemove', function(e) { + if (isPanning && isZoomed) { + const proposedX = e.clientX - startX; + const proposedY = e.clientY - startY; + + const limited = limitPanning(proposedX, proposedY); + translateX = limited.x; + translateY = limited.y; + + const img = slides.current.querySelector('img'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + hasPanned = true; + } +}); + +window.addEventListener('mouseup', function() { + if (isPanning) { + isPanning = false; + container.style.cursor = isZoomed ? 'zoom-out' : 'zoom-in'; + } +}); + +// Touch panning event listeners +container.addEventListener('touchstart', function(e) { + if (isZoomed) { + isPanning = true; + hasPanned = false; + updateBoundaries(); + const touch = e.touches[0]; + startX = touch.clientX - translateX; + startY = touch.clientY - translateY; + e.preventDefault(); + } +}); + +container.addEventListener('touchmove', function(e) { + if (isPanning && isZoomed) { + const touch = e.touches[0]; + const proposedX = touch.clientX - startX; + const proposedY = touch.clientY - startY; + + const limited = limitPanning(proposedX, proposedY); + translateX = limited.x; + translateY = limited.y; + + const img = slides.current.querySelector('img'); + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`; + hasPanned = true; + e.preventDefault(); + } +}); + +container.addEventListener('touchend', function(e) { + if (isPanning) { + isPanning = false; + if (!hasPanned) { + handleZoomTap(e.changedTouches[0]); + } + } +}); From 12ccefcc3bc6d416fcbdbb24302654eaa0d1fc58 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:31:41 +0100 Subject: [PATCH 02/14] Add missing copyright/license information --- data/website_gallery/modal.css | 17 +++++++++++++++++ data/website_gallery/modal.js | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/data/website_gallery/modal.css b/data/website_gallery/modal.css index e7c620da..aa8ca721 100644 --- a/data/website_gallery/modal.css +++ b/data/website_gallery/modal.css @@ -1,3 +1,20 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ + * { margin: 0; padding: 0; diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js index 7258f185..0eeebf1b 100644 --- a/data/website_gallery/modal.js +++ b/data/website_gallery/modal.js @@ -1,5 +1,20 @@ +/* + copyright (c) 2025 Tino Mettler + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this software. If not, see . +*/ -// DOM Elements const container = document.querySelector('.slide-container'); const slides = { prev: document.getElementById('prev'), From 22c9a7cd6ed21b684dab94b6c620594d89658e08 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:32:10 +0100 Subject: [PATCH 03/14] Comments, remove cruft --- data/website_gallery/modal.js | 44 ++--------------------------------- 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/data/website_gallery/modal.js b/data/website_gallery/modal.js index 0eeebf1b..6e9c29bf 100644 --- a/data/website_gallery/modal.js +++ b/data/website_gallery/modal.js @@ -24,13 +24,12 @@ const slides = { const prevArrow = document.getElementById('prevArrow'); const nextArrow = document.getElementById('nextArrow'); -// State management let mouseTimer = null; let startX = 0; let startY = 0; -let isDragging = false; +let isDragging = false; // For swipe navigation let isZoomed = false; -let isPanning = false; +let isPanning = false; // Panning in zoomed state let hasPanned = false; let translateX = 0; let translateY = 0; @@ -57,14 +56,6 @@ function updateBoundaries() { // Calculate the actual scaled dimensions based on the base (pre-zoom) size scaledWidth = baseWidth * currentScale; scaledHeight = baseHeight * currentScale; - console.log('Boundaries:', { - containerRect, - scaledWidth, - scaledHeight, - baseWidth, - baseHeight, - currentScale - }); } function limitPanning(proposedX, proposedY) { @@ -116,15 +107,6 @@ function limitPanning(proposedX, proposedY) { constrainedY = (baseHeight - scaledHeight) / 2; } - console.log('limitPanning:', { - proposed: { x: proposedX, y: proposedY }, - constrained: { x: constrainedX, y: constrainedY }, - limits: { minX, maxX, minY, maxY }, - baseOffset: { x: baseOffsetX, y: baseOffsetY }, - baseSize: { w: baseWidth, h: baseHeight }, - scaledSize: { w: scaledWidth, h: scaledHeight } - }); - return { x: constrainedX, y: constrainedY @@ -299,22 +281,6 @@ async function handleTouchEnd(e) { } } -function handleMouseMove() { - if (isZoomed) return; - - prevArrow.classList.add('visible'); - nextArrow.classList.add('visible'); - - if (mouseTimer) { - clearTimeout(mouseTimer); - } - - mouseTimer = setTimeout(() => { - prevArrow.classList.remove('visible'); - nextArrow.classList.remove('visible'); - }, 1000); -} - function handleKeyDown(e) { if (isZoomed) return; @@ -342,9 +308,6 @@ container.addEventListener('touchmove', handleTouchMove); container.addEventListener('touchend', handleTouchEnd); container.addEventListener('click', handleZoom); -// make nav arrows visible -document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('keydown', handleKeyDown); prevArrow.addEventListener('click', showPreviousImage); nextArrow.addEventListener('click', showNextImage); @@ -417,8 +380,5 @@ container.addEventListener('touchmove', function(e) { container.addEventListener('touchend', function(e) { if (isPanning) { isPanning = false; - if (!hasPanned) { - handleZoomTap(e.changedTouches[0]); - } } }); From 071a3527c1b000a18ccb8c458bb43e0b2c048cf1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 21:32:19 +0100 Subject: [PATCH 04/14] Add new website gallery module to README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 86fcfa9d..5b1cd1b7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports [slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow [transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy [video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable +website_gallery_export|No|LMW|Export a website gallery for selected images ### Example Scripts From 7f4bae4234b883360f3c90b8b3fba4f505ef50e2 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 26 Nov 2025 22:31:10 +0100 Subject: [PATCH 05/14] Update TODO --- contrib/website_gallery_export.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index c691b8b5..f05f9497 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -18,7 +18,6 @@ --[[ TODO: - - before PR: Zoom, export code from wpferguson, use share_dir - Lua: remove images dir if already existent - Lua: implement "supported" callback to limit export to suited file formats - Lua: translations From dfeff73fcd7c4b7e8b392af57762c7e0ff4c38a1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 4 Dec 2025 11:47:36 +0100 Subject: [PATCH 06/14] Add callback to check if export format is supported Currently JPG, TIFF, PNG and WebP are supported. --- contrib/website_gallery_export.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index f05f9497..56237f6a 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -256,6 +256,22 @@ local function initialize(storage, img_format, images, high_quality, extra_data) extra_data["sizes"] = {}; end -dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, nil, initialize, gallery_widget) +local supported_formats = { "jpg", "tif", "png", "webp" } + +local formats_lut = {} +for key,format in pairs(supported_formats) do + formats_lut[format] = true +end + +function check_supported(storage, format) + extension = format.extension + if formats_lut[extension] == true then + return true + else + return false + end +end + +dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, check_supported, initialize, gallery_widget) return script_data From 7f00cb8d6c548769b3808974cabf082cf9f0a6d1 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 4 Dec 2025 19:09:22 +0100 Subject: [PATCH 07/14] Update TODO list for the website gallery export --- contrib/website_gallery_export.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 56237f6a..866ed556 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -19,7 +19,6 @@ --[[ TODO: - Lua: remove images dir if already existent - - Lua: implement "supported" callback to limit export to suited file formats - Lua: translations ]] From a47de82afde1b7cf80b78a3b25fe73cd9b0d8022 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Mon, 8 Dec 2025 14:53:58 +0100 Subject: [PATCH 08/14] Show progress during thumbnail export --- contrib/website_gallery_export.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 866ed556..843efc92 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -74,7 +74,6 @@ function escape_js_string(str) end local function export_thumbnail(image, filename) - dt.print("export thumbnail image "..filename) exporter = dt.new_format("jpeg") exporter.quality = 90 exporter.max_height = 512 @@ -108,14 +107,21 @@ function exiftool_get_image_dimensions(filename) end end +local function stop_job(job) + job.valid = false +end + local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) dest_dir = dest_dir.."/images" local gallery_data = { name = escape_js_string(title) } local images = {} local index = 1 + local job = dt.gui.create_job("exporting thumbnail images", true, stop_job) + for i, image in pairs(images_ordered) do local filename = images_table[image] + dt.print("exporting thumbnail image "..index.."/"..#images_ordered) write_image(image, dest_dir, filename) if exiftool then @@ -129,9 +135,11 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, width = width, height = height } images[index] = entry + job.percent = index / #images_ordered index = index + 1 end + stop_job(job) gallery_data.images = images return gallery_data end @@ -175,7 +183,7 @@ local function copy_static_files(dest_dir) "index.html", "gallery.css", "modal.css", - "modal.js", + "modal.js", "gallery.js", "fullscreen.js" } From c6002e7eb793c32ff24c40e34e45c9f4817c3fdd Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 10 Dec 2025 08:46:11 +0100 Subject: [PATCH 09/14] Make UI text elements translatable --- contrib/website_gallery_export.lua | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index 843efc92..fe837891 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -28,6 +28,10 @@ local df = require "lib/dtutils.file" local temp = dt.preferences.read('web_gallery', 'title', 'string') if temp == nil then temp = 'Darktable gallery' end +local function _(msgid) + return dt.gettext.gettext(msgid) +end + local title_widget = dt.new_widget("entry") { text = temp @@ -38,8 +42,8 @@ if temp == nil then temp = '' end local dest_dir_widget = dt.new_widget("file_chooser_button") { - title = "select output folder", - tooltip = "select output folder", + title = _("select output folder"), + tooltip = _("select output folder"), value = temp, is_directory = true, changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end @@ -48,9 +52,9 @@ local dest_dir_widget = dt.new_widget("file_chooser_button") local gallery_widget = dt.new_widget("box") { orientation=vertical, - dt.new_widget("label"){label = "gallery title"}, + dt.new_widget("label"){label = _("gallery title")}, title_widget, - dt.new_widget("label"){label = "destination directory"}, + dt.new_widget("label"){label = _("destination directory")}, dest_dir_widget } @@ -117,11 +121,11 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, local images = {} local index = 1 - local job = dt.gui.create_job("exporting thumbnail images", true, stop_job) + local job = dt.gui.create_job(_("exporting thumbnail images"), true, stop_job) for i, image in pairs(images_ordered) do local filename = images_table[image] - dt.print("exporting thumbnail image "..index.."/"..#images_ordered) + dt.print(_("exporting thumbnail image ")..index.."/"..#images_ordered) write_image(image, dest_dir, filename) if exiftool then @@ -164,7 +168,7 @@ local function generate_javascript_gallery_object(gallery) end local function write_javascript_file(gallery_table, dest_dir) - dt.print("write JavaScript file") + dt.print(_("write JavaScript file")) javascript_object = generate_javascript_gallery_object(gallery_table) local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') @@ -177,7 +181,7 @@ local function write_javascript_file(gallery_table, dest_dir) end local function copy_static_files(dest_dir) - dt.print("copy static gallery files") + dt.print(_("copy static gallery files")) gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" gfiles = { "index.html", @@ -200,7 +204,7 @@ local function build_gallery(storage, images_table, extra_data) local images_ordered = extra_data["images"] -- process images in the correct order local sizes = extra_data["sizes"] - local title = "Darktable export" + local title = _("Darktable export") if title_widget.text ~= "" then title = title_widget.text end @@ -231,7 +235,7 @@ script_data.destroy = destroy local function show_status(storage, image, format, filename, number, total, high_quality, extra_data) - dt.print(string.format("export image %i/%i", number, total)) + dt.print(string.format(_("export image").."%i/%i", number, total)) aspect = image.aspect_ratio -- calculate the size of the exported image and store it in extra_data -- to make it available in the finalize function From b0373f7166d3690a4411065db5c368cd05d430ba Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 10 Dec 2025 08:46:24 +0100 Subject: [PATCH 10/14] Use subdirectories in the generated gallery JavaScript files are now in js/, CSS files in css/, thumbnail images in thumbnails/. --- contrib/website_gallery_export.lua | 26 +++++++++++---------- data/website_gallery/{ => css}/gallery.css | 0 data/website_gallery/{ => css}/modal.css | 0 data/website_gallery/index.html | 12 +++++----- data/website_gallery/{ => js}/fullscreen.js | 0 data/website_gallery/{ => js}/gallery.js | 2 +- data/website_gallery/{ => js}/modal.js | 0 7 files changed, 21 insertions(+), 19 deletions(-) rename data/website_gallery/{ => css}/gallery.css (100%) rename data/website_gallery/{ => css}/modal.css (100%) rename data/website_gallery/{ => js}/fullscreen.js (100%) rename data/website_gallery/{ => js}/gallery.js (97%) rename data/website_gallery/{ => js}/modal.js (100%) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index fe837891..f485f0b3 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -86,8 +86,8 @@ local function export_thumbnail(image, filename) end local function write_image(image, dest_dir, filename) - df.file_move(filename, dest_dir.."/"..get_file_name(filename)) - export_thumbnail(image, dest_dir.."/thumb_"..get_file_name(filename)) + df.file_move(filename, dest_dir.."/images/"..get_file_name(filename)) + export_thumbnail(image, dest_dir.."/thumbnails/thumb_"..get_file_name(filename)) end function exiftool_get_image_dimensions(filename) @@ -116,7 +116,6 @@ local function stop_job(job) end local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool) - dest_dir = dest_dir.."/images" local gallery_data = { name = escape_js_string(title) } local images = {} @@ -129,7 +128,7 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, write_image(image, dest_dir, filename) if exiftool then - width, height = exiftool_get_image_dimensions(dest_dir.."/"..get_file_name(filename)) + width, height = exiftool_get_image_dimensions(dest_dir.."/images/"..get_file_name(filename)) else width = sizes[index].width height = sizes[index].height @@ -171,7 +170,7 @@ local function write_javascript_file(gallery_table, dest_dir) dt.print(_("write JavaScript file")) javascript_object = generate_javascript_gallery_object(gallery_table) - local fileOut, errr = io.open(dest_dir.."/images.js", 'w+') + local fileOut, errr = io.open(dest_dir.."/js/images.js", 'w+') if fileOut then fileOut:write(javascript_object) else @@ -181,17 +180,17 @@ local function write_javascript_file(gallery_table, dest_dir) end local function copy_static_files(dest_dir) - dt.print(_("copy static gallery files")) gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" - gfiles = { + local gfiles = { "index.html", - "gallery.css", - "modal.css", - "modal.js", - "gallery.js", - "fullscreen.js" + "css/gallery.css", + "css/modal.css", + "js/gallery.js", + "js/modal.js", + "js/fullscreen.js" } + dt.print(_("copy static gallery files")) for _, file in ipairs(gfiles) do df.file_copy(gfsrc.."/"..file, dest_dir.."/"..file) end @@ -201,6 +200,9 @@ local function build_gallery(storage, images_table, extra_data) local dest_dir = dest_dir_widget.value df.mkdir(dest_dir) df.mkdir(dest_dir.."/images") + df.mkdir(dest_dir.."/thumbnails") + df.mkdir(dest_dir.."/css") + df.mkdir(dest_dir.."/js") local images_ordered = extra_data["images"] -- process images in the correct order local sizes = extra_data["sizes"] diff --git a/data/website_gallery/gallery.css b/data/website_gallery/css/gallery.css similarity index 100% rename from data/website_gallery/gallery.css rename to data/website_gallery/css/gallery.css diff --git a/data/website_gallery/modal.css b/data/website_gallery/css/modal.css similarity index 100% rename from data/website_gallery/modal.css rename to data/website_gallery/css/modal.css diff --git a/data/website_gallery/index.html b/data/website_gallery/index.html index e9ea6140..8abf8880 100644 --- a/data/website_gallery/index.html +++ b/data/website_gallery/index.html @@ -4,8 +4,8 @@ - - + + Image Gallery @@ -37,9 +37,9 @@

Image Gallery

- - - - + + + + \ No newline at end of file diff --git a/data/website_gallery/fullscreen.js b/data/website_gallery/js/fullscreen.js similarity index 100% rename from data/website_gallery/fullscreen.js rename to data/website_gallery/js/fullscreen.js diff --git a/data/website_gallery/gallery.js b/data/website_gallery/js/gallery.js similarity index 97% rename from data/website_gallery/gallery.js rename to data/website_gallery/js/gallery.js index 54b0c875..be9a8ed0 100644 --- a/data/website_gallery/gallery.js +++ b/data/website_gallery/js/gallery.js @@ -66,7 +66,7 @@ document.addEventListener('DOMContentLoaded', function () { const img = document.createElement('img'); img.className = 'thumb'; - img.src = imageObj.filename.replace(/images\/(.*)$/i, 'images/thumb_$1'); + img.src = imageObj.filename.replace(/images\/(.*)$/i, 'thumbnails/thumb_$1'); img.alt = imageObj.filename; img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); }); diff --git a/data/website_gallery/modal.js b/data/website_gallery/js/modal.js similarity index 100% rename from data/website_gallery/modal.js rename to data/website_gallery/js/modal.js From bb3a74feac573cf6dda2967a4ab7efde88deff72 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Wed, 17 Dec 2025 21:26:28 +0100 Subject: [PATCH 11/14] CSS cleanup, separate functional and visual CSS code Added file style.css which is aimed to contain CSS code that affects the look of the gallery. This could be extended to provide different or user-modified styles to give each gallery an individual look. --- data/website_gallery/css/gallery.css | 65 ++------------------------- data/website_gallery/css/modal.css | 37 +++++---------- data/website_gallery/css/style.css | 67 ++++++++++++++++++++++++++++ data/website_gallery/index.html | 1 + 4 files changed, 83 insertions(+), 87 deletions(-) create mode 100644 data/website_gallery/css/style.css diff --git a/data/website_gallery/css/gallery.css b/data/website_gallery/css/gallery.css index 5d05a0e1..9bfa9b1b 100644 --- a/data/website_gallery/css/gallery.css +++ b/data/website_gallery/css/gallery.css @@ -16,50 +16,27 @@ */ body { - font-family: system-ui, Arial, Helvetica, sans-serif; - background-color: rgb(238, 240, 242); + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; } .heading h1 { text-align: center; - background-color: #cdd; - color: black; display: grid; } -.heading h1:after { - content: " "; - border-bottom: 1px solid #888888; - height: 1px; -} - .gallery { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; - gap: 3vw; - margin: 3vw auto; - padding: 1.5vw; -} - -.slider { - margin: 0; - padding: 0; } - .gallery img { cursor: pointer; object-fit: contain; - background: none; - padding: 0; - border: 1px outset #737780; - -} - -.gallery img:hover { - transform: scale(1.08); } .thumb { @@ -76,38 +53,4 @@ body { align-items: center; object-fit: contain; justify-content: center; - background: none; - box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6); -} - -.navgrid { - font-size: 3.5vh; - display: grid; - grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em; - grid-column-gap: 1vw; - background-color: black; -} - -.nav { - height: 5vh; - background-color: rgba(60,60,60, 0.4); - justify-content: center; - align-items: center; - display: flex; - z-index: 1001; - color: #ccc; -} - -.counter { - font-size: 2.5vh; -} - -.button { - cursor: pointer; -} - -.button:hover { - cursor: pointer; - color: white; - text-shadow: 0px 0px 10px #ccc; } diff --git a/data/website_gallery/css/modal.css b/data/website_gallery/css/modal.css index aa8ca721..0e460af5 100644 --- a/data/website_gallery/css/modal.css +++ b/data/website_gallery/css/modal.css @@ -20,15 +20,6 @@ padding: 0; box-sizing: border-box; } -body { - margin: 0; - padding: 0; - width: 100vw; - height: 100vh; - background: #000; - display: flex; - flex-direction: column; -} .viewer { position: relative; @@ -39,10 +30,20 @@ body { display: none; } +.navgrid { + display: grid; +} + +.nav { + justify-content: center; + align-items: center; + display: flex; + z-index: 1001; +} + .slider { position: relative; width: 100vw; - height: 95vh; touch-action: none; overflow: hidden; } @@ -69,8 +70,6 @@ body { will-change: contents; overflow: hidden; position: relative; - padding: 0; - margin: 0; touch-action: none; object-fit: contain; } @@ -80,8 +79,6 @@ body { max-height: 100%; width: auto; height: auto; - padding: 0; - margin: 0; display: block; transform-origin: 0 0; cursor: zoom-in; @@ -98,15 +95,3 @@ body { transition: transform 0.2s ease-out; } -.nav-bar { - width: 100vw; - height: 5vh; - background: #333; - color: #fff; - display: flex; - align-items: center; - padding-left: 20px; - box-sizing: border-box; - font-family: Arial, sans-serif; - font-size: 16px; -} diff --git a/data/website_gallery/css/style.css b/data/website_gallery/css/style.css new file mode 100644 index 00000000..c8196f6e --- /dev/null +++ b/data/website_gallery/css/style.css @@ -0,0 +1,67 @@ +body { + font-family: system-ui, Arial, Helvetica, sans-serif; + background-color: rgb(238, 240, 242); +} + +.heading h1 { + background-color: #cdd; + color: black; +} + +.heading h1:after { + content: " "; + border-bottom: 1px solid #888888; + height: 1px; +} + +.gallery { + gap: 3vw; + margin: 3vw auto; + padding: 1.5vw; +} + +.gallery img { + background: none; + border: 1px outset #737780; + +} + +.gallery img:hover { + transform: scale(1.08); +} + +.thumb-box { + background: none; + box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6); +} + +.nav { + height: 5vh; + background-color: rgba(60,60,60, 0.4); + color: #ccc; +} + +.slider { + height: 95vh; /* remaining vh after nav vh */ +} + +.navgrid { + grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em; + font-size: 3.5vh; + grid-column-gap: 1vw; + background-color: black; +} + +.button { + cursor: pointer; +} + +.button:hover { + cursor: pointer; + color: white; + text-shadow: 0px 0px 10px #ccc; +} + +.counter { + font-size: 2.5vh; +} diff --git a/data/website_gallery/index.html b/data/website_gallery/index.html index 8abf8880..c34188b8 100644 --- a/data/website_gallery/index.html +++ b/data/website_gallery/index.html @@ -6,6 +6,7 @@ + Image Gallery From c584ee05c9bc8fdfb38f323d6b209879e529f135 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 18 Dec 2025 08:02:43 +0100 Subject: [PATCH 12/14] Remove unused JavaScript code and outdated comments --- data/website_gallery/js/gallery.js | 1 - data/website_gallery/js/modal.js | 29 ----------------------------- 2 files changed, 30 deletions(-) diff --git a/data/website_gallery/js/gallery.js b/data/website_gallery/js/gallery.js index be9a8ed0..3ca7745f 100644 --- a/data/website_gallery/js/gallery.js +++ b/data/website_gallery/js/gallery.js @@ -58,7 +58,6 @@ document.addEventListener('DOMContentLoaded', function () { const width = parseInt(imageObj.width); const height = parseInt(imageObj.height); - const aspect = height / width; const sum = width + height; const scalefactor = sum / (framesize * 2.0); frame.style.width = (width / scalefactor) + 'vw'; diff --git a/data/website_gallery/js/modal.js b/data/website_gallery/js/modal.js index 6e9c29bf..89929733 100644 --- a/data/website_gallery/js/modal.js +++ b/data/website_gallery/js/modal.js @@ -24,7 +24,6 @@ const slides = { const prevArrow = document.getElementById('prevArrow'); const nextArrow = document.getElementById('nextArrow'); -let mouseTimer = null; let startX = 0; let startY = 0; let isDragging = false; // For swipe navigation @@ -34,13 +33,9 @@ let hasPanned = false; let translateX = 0; let translateY = 0; let currentScale = 1; -let minX = 0; -let minY = 0; -let containerRect, imgRect; let img; let baseWidth, baseHeight; // Image dimensions before zoom -let baseOffsetX, baseOffsetY; // Image position offset before zoom (due to centering) let scaledWidth, scaledHeight; @@ -59,28 +54,6 @@ function updateBoundaries() { } function limitPanning(proposedX, proposedY) { - // With transform-origin: 0 0 and transform: translate(tx, ty) scale(s) - // - // Before zoom: - // - Image element positioned at (baseOffsetX, baseOffsetY) in container - // - Image size is (baseWidth, baseHeight) - // - // After transform is applied: - // - First, scale happens around origin (0,0) of the element: element becomes (baseWidth*s, baseHeight*s) - // - Then translate by (tx, ty) moves the whole element - // - Final position in viewport: element's top-left is at (baseOffsetX + tx, baseOffsetY + ty) - // - Element's bottom-right is at (baseOffsetX + tx + scaledWidth, baseOffsetY + ty + scaledHeight) - // - // We want the image content edges to stay within the container while allowing original borders: - // - Left constraint: baseOffsetX + tx >= baseOffsetX => tx >= 0 - // - Right constraint: baseOffsetX + tx + scaledWidth <= containerWidth - (containerWidth - baseOffsetX - baseWidth) - // baseOffsetX + tx + scaledWidth <= baseOffsetX + baseWidth - // tx <= baseWidth - scaledWidth - // - Top constraint: baseOffsetY + ty >= baseOffsetY => ty >= 0 - // - Bottom constraint: baseOffsetY + ty + scaledHeight <= baseOffsetY + baseHeight - // ty <= baseHeight - scaledHeight - - // Calculate limits const maxX = 0; const minX = baseWidth - scaledWidth; @@ -147,8 +120,6 @@ function handleZoom(e) { // Store the base (pre-zoom) dimensions and offset baseWidth = rect.width; baseHeight = rect.height; - baseOffsetX = rect.left; - baseOffsetY = rect.top; // Calculate the scale for 1:1 pixel zoom currentScale = img.naturalWidth / rect.width; From 2b3910c698b593711cf367adb7e6ad42130d7b41 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 18 Dec 2025 08:19:14 +0100 Subject: [PATCH 13/14] CSS style cleanup --- data/website_gallery/css/modal.css | 2 ++ data/website_gallery/css/style.css | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/data/website_gallery/css/modal.css b/data/website_gallery/css/modal.css index 0e460af5..92036aab 100644 --- a/data/website_gallery/css/modal.css +++ b/data/website_gallery/css/modal.css @@ -35,6 +35,7 @@ } .nav { + height: var(--navigation-bar-height); justify-content: center; align-items: center; display: flex; @@ -42,6 +43,7 @@ } .slider { + height: calc(100vh - var(--navigation-bar-height)); position: relative; width: 100vw; touch-action: none; diff --git a/data/website_gallery/css/style.css b/data/website_gallery/css/style.css index c8196f6e..c9f1200c 100644 --- a/data/website_gallery/css/style.css +++ b/data/website_gallery/css/style.css @@ -35,16 +35,15 @@ body { box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6); } +.viewer { + --navigation-bar-height: 5vh; +} + .nav { - height: 5vh; background-color: rgba(60,60,60, 0.4); color: #ccc; } -.slider { - height: 95vh; /* remaining vh after nav vh */ -} - .navgrid { grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em; font-size: 3.5vh; From 7a8f3afc40f32fb3d0a3f452a180f5439eb05290 Mon Sep 17 00:00:00 2001 From: Tino Mettler Date: Thu, 18 Dec 2025 17:21:30 +0100 Subject: [PATCH 14/14] Portability: use system specific path separator, sanitize filenames --- contrib/website_gallery_export.lua | 46 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua index f485f0b3..241841b1 100644 --- a/contrib/website_gallery_export.lua +++ b/contrib/website_gallery_export.lua @@ -25,6 +25,8 @@ local dt = require "darktable" local df = require "lib/dtutils.file" +local PS = dt.configuration.running_os == "windows" and "\\" or "/" + local temp = dt.preferences.read('web_gallery', 'title', 'string') if temp == nil then temp = 'Darktable gallery' end @@ -59,7 +61,7 @@ local gallery_widget = dt.new_widget("box") } local function get_file_name(file) - return file:match("[^/]*.$") + return file:match("[^" .. PS .. "]*.$") end function escape_js_string(str) @@ -86,8 +88,8 @@ local function export_thumbnail(image, filename) end local function write_image(image, dest_dir, filename) - df.file_move(filename, dest_dir.."/images/"..get_file_name(filename)) - export_thumbnail(image, dest_dir.."/thumbnails/thumb_"..get_file_name(filename)) + df.file_move(filename, dest_dir.. PS .. "images" .. PS .. get_file_name(filename)) + export_thumbnail(image, dest_dir .. PS .. "thumbnails" .. PS .. "thumb_" .. get_file_name(filename)) end function exiftool_get_image_dimensions(filename) @@ -123,18 +125,18 @@ local function fill_gallery_table(images_ordered, images_table, title, dest_dir, local job = dt.gui.create_job(_("exporting thumbnail images"), true, stop_job) for i, image in pairs(images_ordered) do - local filename = images_table[image] - dt.print(_("exporting thumbnail image ")..index.."/"..#images_ordered) + local filename = df.sanitize_filename(images_table[image]) + dt.print(_("export thumbnail image ") .. index .. "/" .. #images_ordered) write_image(image, dest_dir, filename) if exiftool then - width, height = exiftool_get_image_dimensions(dest_dir.."/images/"..get_file_name(filename)) + width, height = exiftool_get_image_dimensions(df.sanitize_filename(dest_dir .. PS .. "images" .. PS .. get_file_name(filename))) else width = sizes[index].width height = sizes[index].height end - local entry = { filename = "images/"..get_file_name(escape_js_string(filename)), + local entry = { filename = "images" .. PS .. get_file_name(filename), width = width, height = height } images[index] = entry @@ -170,7 +172,7 @@ local function write_javascript_file(gallery_table, dest_dir) dt.print(_("write JavaScript file")) javascript_object = generate_javascript_gallery_object(gallery_table) - local fileOut, errr = io.open(dest_dir.."/js/images.js", 'w+') + local fileOut, errr = io.open(dest_dir .. PS .. "js" .. PS .. "images.js", 'w+') if fileOut then fileOut:write(javascript_object) else @@ -180,29 +182,31 @@ local function write_javascript_file(gallery_table, dest_dir) end local function copy_static_files(dest_dir) - gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery" + + gfsrc = dt.configuration.config_dir .. PS .. "lua" .. PS .. "data" .. PS .. "website_gallery" local gfiles = { "index.html", - "css/gallery.css", - "css/modal.css", - "js/gallery.js", - "js/modal.js", - "js/fullscreen.js" + "css" .. PS .. "gallery.css", + "css" .. PS .. "modal.css", + "css" .. PS .. "style.css", + "js" .. PS .. "gallery.js", + "js" .. PS .. "modal.js", + "js" .. PS .. "fullscreen.js" } dt.print(_("copy static gallery files")) for _, file in ipairs(gfiles) do - df.file_copy(gfsrc.."/"..file, dest_dir.."/"..file) + df.file_copy(gfsrc .. PS .. file, dest_dir .. PS .. file) end end local function build_gallery(storage, images_table, extra_data) local dest_dir = dest_dir_widget.value - df.mkdir(dest_dir) - df.mkdir(dest_dir.."/images") - df.mkdir(dest_dir.."/thumbnails") - df.mkdir(dest_dir.."/css") - df.mkdir(dest_dir.."/js") + df.mkdir(df.sanitize_filename(dest_dir)) + df.mkdir(df.sanitize_filename(dest_dir .. PS .. "images")) + df.mkdir(df.sanitize_filename(dest_dir .. PS .. "thumbnails")) + df.mkdir(df.sanitize_filename(dest_dir .. PS .. "css")) + df.mkdir(df.sanitize_filename(dest_dir .. PS .. "js")) local images_ordered = extra_data["images"] -- process images in the correct order local sizes = extra_data["sizes"] @@ -237,7 +241,7 @@ script_data.destroy = destroy local function show_status(storage, image, format, filename, number, total, high_quality, extra_data) - dt.print(string.format(_("export image").."%i/%i", number, total)) + dt.print(string.format(_("export image ").."%i/%i", number, total)) aspect = image.aspect_ratio -- calculate the size of the exported image and store it in extra_data -- to make it available in the finalize function