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.
This commit is contained in:
262
contrib/website_gallery_export.lua
Normal file
262
contrib/website_gallery_export.lua
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
]]
|
||||||
|
|
||||||
|
--[[
|
||||||
|
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 <tino+darktable@tikei.de>",
|
||||||
|
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
|
||||||
40
data/website_gallery/fullscreen.js
Normal file
40
data/website_gallery/fullscreen.js
Normal file
@@ -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.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
113
data/website_gallery/gallery.css
Normal file
113
data/website_gallery/gallery.css
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
113
data/website_gallery/gallery.js
Normal file
113
data/website_gallery/gallery.js
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
45
data/website_gallery/index.html
Normal file
45
data/website_gallery/index.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="modal.css">
|
||||||
|
<link rel="stylesheet" href="gallery.css">
|
||||||
|
<title id="page-title">Image Gallery</title>
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="heading1" class="heading">
|
||||||
|
<h1 id="gallery-title">Image Gallery</h1>
|
||||||
|
</div>
|
||||||
|
<div id="gallery" class="gallery"></div>
|
||||||
|
<div class="viewer" id="viewer">
|
||||||
|
<div class="navgrid">
|
||||||
|
<div id="counter" class="nav"></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="button nav nav-arrow" id="prevArrow">◀</div>
|
||||||
|
<div class="button nav nav-arrow" id="nextArrow">▶</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="fullscreen" class="button nav">⛶</div>
|
||||||
|
<div id="close" class="nav button">×</div>
|
||||||
|
</div>
|
||||||
|
<div class="slider" id="slider1">
|
||||||
|
<div class="slide-container">
|
||||||
|
<div class="slide" id="prev"></div>
|
||||||
|
<div class="slide" id="current"></div>
|
||||||
|
<div class="slide" id="next"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type='text/javascript' >
|
||||||
|
var currentIndex = 1;
|
||||||
|
</script>
|
||||||
|
<script src="images.js"></script>
|
||||||
|
<script src="fullscreen.js"></script>
|
||||||
|
<script src="modal.js"></script>
|
||||||
|
<script src="gallery.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
95
data/website_gallery/modal.css
Normal file
95
data/website_gallery/modal.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
409
data/website_gallery/modal.js
Normal file
409
data/website_gallery/modal.js
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user