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; + } +});