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
diff --git a/contrib/website_gallery_export.lua b/contrib/website_gallery_export.lua
new file mode 100644
index 00000000..f485f0b3
--- /dev/null
+++ b/contrib/website_gallery_export.lua
@@ -0,0 +1,290 @@
+--[[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:
+ - Lua: remove images dir if already existent
+ - 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 function _(msgid)
+ return dt.gettext.gettext(msgid)
+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)
+ 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.."/images/"..get_file_name(filename))
+ export_thumbnail(image, dest_dir.."/thumbnails/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 stop_job(job)
+ job.valid = false
+end
+
+local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool)
+ 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
+ width, height = exiftool_get_image_dimensions(dest_dir.."/images/"..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
+ job.percent = index / #images_ordered
+ index = index + 1
+ end
+
+ stop_job(job)
+ 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.."/js/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)
+ gfsrc = dt.configuration.config_dir.."/lua/data/website_gallery"
+ local gfiles = {
+ "index.html",
+ "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
+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")
+
+ 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
+
+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
diff --git a/data/website_gallery/css/gallery.css b/data/website_gallery/css/gallery.css
new file mode 100644
index 00000000..5d05a0e1
--- /dev/null
+++ b/data/website_gallery/css/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/css/modal.css b/data/website_gallery/css/modal.css
new file mode 100644
index 00000000..aa8ca721
--- /dev/null
+++ b/data/website_gallery/css/modal.css
@@ -0,0 +1,112 @@
+/*
+ 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;
+ 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/index.html b/data/website_gallery/index.html
new file mode 100644
index 00000000..8abf8880
--- /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/js/fullscreen.js b/data/website_gallery/js/fullscreen.js
new file mode 100644
index 00000000..c14b88cd
--- /dev/null
+++ b/data/website_gallery/js/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/js/gallery.js b/data/website_gallery/js/gallery.js
new file mode 100644
index 00000000..be9a8ed0
--- /dev/null
+++ b/data/website_gallery/js/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, 'thumbnails/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/js/modal.js b/data/website_gallery/js/modal.js
new file mode 100644
index 00000000..6e9c29bf
--- /dev/null
+++ b/data/website_gallery/js/modal.js
@@ -0,0 +1,384 @@
+/*
+ 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 .
+*/
+
+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');
+
+let mouseTimer = null;
+let startX = 0;
+let startY = 0;
+let isDragging = false; // For swipe navigation
+let isZoomed = false;
+let isPanning = false; // Panning in zoomed state
+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;
+}
+
+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;
+ }
+
+ 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 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);
+
+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;
+ }
+});