feat: add fullscreen photo navigation
This commit is contained in:
5
src/assets/overlay-arrow-small-left.svg
Normal file
5
src/assets/overlay-arrow-small-left.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<HTML><HEAD><TITLE>Error</TITLE></HEAD><BODY>
|
||||||
|
An error occurred while processing your request.<p>
|
||||||
|
Reference #199.85461402.1781428260.4adffb46
|
||||||
|
<P>https://errors.edgesuite.net/199.85461402.1781428260.4adffb46</P>
|
||||||
|
</BODY></HTML>
|
||||||
5
src/assets/overlay-arrow-small-right.svg
Normal file
5
src/assets/overlay-arrow-small-right.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<HTML><HEAD><TITLE>Error</TITLE></HEAD><BODY>
|
||||||
|
An error occurred while processing your request.<p>
|
||||||
|
Reference #199.84461402.1781428258.69d99949
|
||||||
|
<P>https://errors.edgesuite.net/199.84461402.1781428258.69d99949</P>
|
||||||
|
</BODY></HTML>
|
||||||
@@ -32,7 +32,9 @@ let scheduledRenderTimer = null;
|
|||||||
let scheduledRenderOptions = { fitMap: false };
|
let scheduledRenderOptions = { fitMap: false };
|
||||||
let lastRenderAt = 0;
|
let lastRenderAt = 0;
|
||||||
const SHARE_PARAM_NAME = "share";
|
const SHARE_PARAM_NAME = "share";
|
||||||
let overlayMapView = null;
|
const PHOTO_PARAM_NAME = "photo";
|
||||||
|
const OVERLAY_ZOOM_LEVEL = 16;
|
||||||
|
let pendingOverlayPhotoName = "";
|
||||||
|
|
||||||
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
|
const map = L.map("map", { zoomControl: true }).setView([52.5208, 13.4095], 13);
|
||||||
const lightMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
|
const lightMapLayer = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
|
||||||
@@ -70,6 +72,9 @@ const overlayTitle = mustGet("overlay-title");
|
|||||||
const overlayImage = mustGet("overlay-image");
|
const overlayImage = mustGet("overlay-image");
|
||||||
const overlayLoading = mustGet("overlay-loading");
|
const overlayLoading = mustGet("overlay-loading");
|
||||||
const overlayLoadingText = mustGet("overlay-loading-text");
|
const overlayLoadingText = mustGet("overlay-loading-text");
|
||||||
|
const overlayMedia = mustGet("overlay-media");
|
||||||
|
const overlayPrev = mustGet("overlay-prev");
|
||||||
|
const overlayNext = mustGet("overlay-next");
|
||||||
const closeOverlay = mustGet("close-overlay");
|
const closeOverlay = mustGet("close-overlay");
|
||||||
const routeToggle = mustGet("route-toggle");
|
const routeToggle = mustGet("route-toggle");
|
||||||
const routeToggleIcon = mustGet("route-toggle-icon");
|
const routeToggleIcon = mustGet("route-toggle-icon");
|
||||||
@@ -249,25 +254,104 @@ function setMapTheme(theme) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureMapView() {
|
function getPhotoNameFromLocation() {
|
||||||
const center = map.getCenter();
|
const value = new URL(window.location.href).searchParams.get(PHOTO_PARAM_NAME);
|
||||||
return {
|
return value ? value.trim() : "";
|
||||||
center: [center.lat, center.lng],
|
|
||||||
zoom: map.getZoom()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberOverlayMapView() {
|
function syncPhotoNameInLocation(photoName) {
|
||||||
overlayMapView = captureMapView();
|
const nextUrl = new URL(window.location.href);
|
||||||
|
const trimmed = photoName.trim();
|
||||||
|
|
||||||
|
if (trimmed) {
|
||||||
|
nextUrl.searchParams.set(PHOTO_PARAM_NAME, trimmed);
|
||||||
|
} else {
|
||||||
|
nextUrl.searchParams.delete(PHOTO_PARAM_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreOverlayMapView() {
|
if (nextUrl.toString() !== window.location.href) {
|
||||||
if (!overlayMapView) {
|
window.history.replaceState({}, "", nextUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverlayPhotos() {
|
||||||
|
if (!state.visiblePhotos.length) {
|
||||||
|
return state.photos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activePhotoId && state.visiblePhotos.some((photo) => photo.id === state.activePhotoId)) {
|
||||||
|
return state.visiblePhotos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.photos.length ? state.photos : state.visiblePhotos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPhotoByName(photoName, photos = state.photos) {
|
||||||
|
return photos.find((photo) => photo.name === photoName) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomMapToPhoto(photo) {
|
||||||
|
if (photo.latitude === null || photo.longitude === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
map.setView(overlayMapView.center, overlayMapView.zoom, { animate: false });
|
const targetZoom = Math.min(18, Math.max(map.getZoom(), OVERLAY_ZOOM_LEVEL));
|
||||||
overlayMapView = null;
|
map.setView([photo.latitude, photo.longitude], targetZoom, { animate: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlayNavigationState() {
|
||||||
|
const photos = getOverlayPhotos();
|
||||||
|
const hasNavigation = photos.length > 1;
|
||||||
|
overlayPrev.disabled = !hasNavigation;
|
||||||
|
overlayNext.disabled = !hasNavigation;
|
||||||
|
overlayPrev.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
|
||||||
|
overlayNext.setAttribute("aria-hidden", hasNavigation ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPhotoOverlay(photo, options = {}) {
|
||||||
|
const photoName = photo.name ?? "";
|
||||||
|
pendingOverlayPhotoName = "";
|
||||||
|
state.activePhotoId = photo.id;
|
||||||
|
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
|
||||||
|
updateOverlayNavigationState();
|
||||||
|
|
||||||
|
if (options.syncLocation !== false) {
|
||||||
|
syncPhotoNameInLocation(photoName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.zoomMap !== false) {
|
||||||
|
zoomMapToPhoto(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
openOverlay(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateOverlayPhoto(direction) {
|
||||||
|
const photos = getOverlayPhotos();
|
||||||
|
if (photos.length < 2 || !state.activePhotoId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = photos.findIndex((photo) => photo.id === state.activePhotoId);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = (index + direction + photos.length) % photos.length;
|
||||||
|
openPhotoOverlay(photos[nextIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryOpenPendingPhoto() {
|
||||||
|
if (!pendingOverlayPhotoName || overlay.classList.contains("open")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPhoto = findPhotoByName(pendingOverlayPhotoName);
|
||||||
|
if (!targetPhoto) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openPhotoOverlay(targetPhoto);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRouteVisible(visible) {
|
function setRouteVisible(visible) {
|
||||||
@@ -516,7 +600,8 @@ function closeOverlayView() {
|
|||||||
overlayLoading.classList.remove("error");
|
overlayLoading.classList.remove("error");
|
||||||
overlayLoadingText.textContent = "Loading full-size image...";
|
overlayLoadingText.textContent = "Loading full-size image...";
|
||||||
overlayImage.removeAttribute("src");
|
overlayImage.removeAttribute("src");
|
||||||
restoreOverlayMapView();
|
syncPhotoNameInLocation("");
|
||||||
|
overlaySwipeState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeOverlay.addEventListener("click", closeOverlayView);
|
closeOverlay.addEventListener("click", closeOverlayView);
|
||||||
@@ -526,6 +611,75 @@ overlay.addEventListener("click", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
overlayPrev.addEventListener("click", () => {
|
||||||
|
navigateOverlayPhoto(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlayNext.addEventListener("click", () => {
|
||||||
|
navigateOverlayPhoto(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let overlaySwipeState = null;
|
||||||
|
|
||||||
|
overlayMedia.addEventListener("pointerdown", (event) => {
|
||||||
|
if (!overlay.classList.contains("open") || event.button !== 0 || event.pointerType === "mouse") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaySwipeState = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
moved: false
|
||||||
|
};
|
||||||
|
|
||||||
|
overlayMedia.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlayMedia.addEventListener("pointermove", (event) => {
|
||||||
|
if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - overlaySwipeState.startX;
|
||||||
|
const deltaY = event.clientY - overlaySwipeState.startY;
|
||||||
|
overlaySwipeState.moved = overlaySwipeState.moved || Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10;
|
||||||
|
});
|
||||||
|
|
||||||
|
function finishOverlaySwipe(event) {
|
||||||
|
if (!overlaySwipeState || overlaySwipeState.pointerId !== event.pointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
overlayMedia.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Pointer capture may already be released.
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = event.clientX - overlaySwipeState.startX;
|
||||||
|
const deltaY = event.clientY - overlaySwipeState.startY;
|
||||||
|
const isHorizontalSwipe = Math.abs(deltaX) > 60 && Math.abs(deltaX) > Math.abs(deltaY) * 1.2;
|
||||||
|
|
||||||
|
if (isHorizontalSwipe) {
|
||||||
|
navigateOverlayPhoto(deltaX < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaySwipeState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayMedia.addEventListener("pointerup", finishOverlaySwipe);
|
||||||
|
overlayMedia.addEventListener("pointercancel", () => {
|
||||||
|
if (overlaySwipeState) {
|
||||||
|
try {
|
||||||
|
overlayMedia.releasePointerCapture(overlaySwipeState.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Pointer capture may already be released.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlaySwipeState = null;
|
||||||
|
});
|
||||||
|
|
||||||
function updateRouteView(photos, options = {}) {
|
function updateRouteView(photos, options = {}) {
|
||||||
const routePoints = photos
|
const routePoints = photos
|
||||||
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
|
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
|
||||||
@@ -697,10 +851,7 @@ function renderVisiblePhotos(options = {}) {
|
|||||||
|
|
||||||
marker.on("mouseover", () => marker.openPopup());
|
marker.on("mouseover", () => marker.openPopup());
|
||||||
marker.on("click", () => {
|
marker.on("click", () => {
|
||||||
state.activePhotoId = photo.id;
|
openPhotoOverlay(photo);
|
||||||
rememberOverlayMapView();
|
|
||||||
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
|
|
||||||
openOverlay(photo);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,10 +863,7 @@ function renderVisiblePhotos(options = {}) {
|
|||||||
item.innerHTML =
|
item.innerHTML =
|
||||||
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />';
|
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />';
|
||||||
item.addEventListener("click", () => {
|
item.addEventListener("click", () => {
|
||||||
state.activePhotoId = photo.id;
|
openPhotoOverlay(photo);
|
||||||
rememberOverlayMapView();
|
|
||||||
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
|
|
||||||
openOverlay(photo);
|
|
||||||
});
|
});
|
||||||
photoList.appendChild(item);
|
photoList.appendChild(item);
|
||||||
}
|
}
|
||||||
@@ -723,6 +871,7 @@ function renderVisiblePhotos(options = {}) {
|
|||||||
|
|
||||||
updateRouteView(visiblePhotos, options);
|
updateRouteView(visiblePhotos, options);
|
||||||
renderTimeline();
|
renderTimeline();
|
||||||
|
updateOverlayNavigationState();
|
||||||
|
|
||||||
if (options.fitMap && visiblePhotos.length) {
|
if (options.fitMap && visiblePhotos.length) {
|
||||||
const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
|
const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
|
||||||
@@ -916,6 +1065,7 @@ timelineClearButton.addEventListener("click", clearTimelineSelection);
|
|||||||
function appendPhoto(photo) {
|
function appendPhoto(photo) {
|
||||||
state.photos.push(photo);
|
state.photos.push(photo);
|
||||||
scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 });
|
scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 });
|
||||||
|
tryOpenPendingPhoto();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImportListing(shareUrlValue, signal) {
|
async function loadImportListing(shareUrlValue, signal) {
|
||||||
@@ -1076,6 +1226,7 @@ stopImportButton.addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initialShareUrl = getShareUrlFromLocation();
|
const initialShareUrl = getShareUrlFromLocation();
|
||||||
|
pendingOverlayPhotoName = getPhotoNameFromLocation();
|
||||||
if (initialShareUrl) {
|
if (initialShareUrl) {
|
||||||
importUrlInput.value = initialShareUrl;
|
importUrlInput.value = initialShareUrl;
|
||||||
window.queueMicrotask(() => {
|
window.queueMicrotask(() => {
|
||||||
|
|||||||
@@ -621,6 +621,8 @@ export const APP_STYLES = ` :root {
|
|||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: var(--timeline-text);
|
color: var(--timeline-text);
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-media {
|
.overlay-media {
|
||||||
@@ -728,6 +730,41 @@ export const APP_STYLES = ` :root {
|
|||||||
border-color: var(--timeline-border-strong);
|
border-color: var(--timeline-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1001;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--timeline-surface-soft);
|
||||||
|
color: var(--timeline-text);
|
||||||
|
border: 1px solid var(--timeline-border);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav:hover {
|
||||||
|
background: var(--timeline-surface-strong);
|
||||||
|
border-color: var(--timeline-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-prev {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-next {
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-nav[disabled] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
width: min(210px, 100%);
|
width: min(210px, 100%);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -140,21 +140,35 @@ ${APP_STYLES}
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="overlay" id="overlay" aria-hidden="true">
|
<div class="overlay" id="overlay" aria-hidden="true">
|
||||||
|
<button class="overlay-nav overlay-prev" id="overlay-prev" type="button" aria-label="Previous photo">
|
||||||
|
<span class="button-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.5 4 5.5 8l4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button class="close overlay-close" id="close-overlay" type="button" aria-label="Close photo">
|
<button class="close overlay-close" id="close-overlay" type="button" aria-label="Close photo">
|
||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
<div class="overlay-card">
|
<div class="overlay-card" id="overlay-card">
|
||||||
<div class="overlay-media">
|
<div class="overlay-media" id="overlay-media">
|
||||||
<div class="overlay-loading" id="overlay-loading" aria-live="polite" aria-atomic="true">
|
<div class="overlay-loading" id="overlay-loading" aria-live="polite" aria-atomic="true">
|
||||||
<span class="spinner active" aria-hidden="true"></span>
|
<span class="spinner active" aria-hidden="true"></span>
|
||||||
<strong id="overlay-loading-text">Loading full-size image...</strong>
|
<strong id="overlay-loading-text">Loading full-size image...</strong>
|
||||||
</div>
|
</div>
|
||||||
<img id="overlay-image" alt="" />
|
<img id="overlay-image" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<footer class="overlay-caption">
|
<footer class="overlay-caption" id="overlay-caption">
|
||||||
<strong id="overlay-title">Photo</strong>
|
<strong id="overlay-title">Photo</strong>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="overlay-nav overlay-next" id="overlay-next" type="button" aria-label="Next photo">
|
||||||
|
<span class="button-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.5 4 10.5 8l-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/vendor/leaflet/leaflet.js"></script>
|
<script src="/vendor/leaflet/leaflet.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user