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 lastRenderAt = 0;
|
||||
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 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 overlayLoading = mustGet("overlay-loading");
|
||||
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 routeToggle = mustGet("route-toggle");
|
||||
const routeToggleIcon = mustGet("route-toggle-icon");
|
||||
@@ -249,25 +254,104 @@ function setMapTheme(theme) {
|
||||
});
|
||||
}
|
||||
|
||||
function captureMapView() {
|
||||
const center = map.getCenter();
|
||||
return {
|
||||
center: [center.lat, center.lng],
|
||||
zoom: map.getZoom()
|
||||
};
|
||||
function getPhotoNameFromLocation() {
|
||||
const value = new URL(window.location.href).searchParams.get(PHOTO_PARAM_NAME);
|
||||
return value ? value.trim() : "";
|
||||
}
|
||||
|
||||
function rememberOverlayMapView() {
|
||||
overlayMapView = captureMapView();
|
||||
function syncPhotoNameInLocation(photoName) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (nextUrl.toString() !== window.location.href) {
|
||||
window.history.replaceState({}, "", nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreOverlayMapView() {
|
||||
if (!overlayMapView) {
|
||||
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;
|
||||
}
|
||||
|
||||
map.setView(overlayMapView.center, overlayMapView.zoom, { animate: false });
|
||||
overlayMapView = null;
|
||||
const targetZoom = Math.min(18, Math.max(map.getZoom(), OVERLAY_ZOOM_LEVEL));
|
||||
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) {
|
||||
@@ -516,7 +600,8 @@ function closeOverlayView() {
|
||||
overlayLoading.classList.remove("error");
|
||||
overlayLoadingText.textContent = "Loading full-size image...";
|
||||
overlayImage.removeAttribute("src");
|
||||
restoreOverlayMapView();
|
||||
syncPhotoNameInLocation("");
|
||||
overlaySwipeState = null;
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const routePoints = photos
|
||||
.filter((photo) => photo.latitude !== null && photo.longitude !== null && photo.capturedAt)
|
||||
@@ -697,10 +851,7 @@ function renderVisiblePhotos(options = {}) {
|
||||
|
||||
marker.on("mouseover", () => marker.openPopup());
|
||||
marker.on("click", () => {
|
||||
state.activePhotoId = photo.id;
|
||||
rememberOverlayMapView();
|
||||
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
|
||||
openOverlay(photo);
|
||||
openPhotoOverlay(photo);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -712,10 +863,7 @@ function renderVisiblePhotos(options = {}) {
|
||||
item.innerHTML =
|
||||
'<img src="' + escapeHtml(photo.thumbUrl) + '" alt="' + escapeHtml(photo.name) + '" />';
|
||||
item.addEventListener("click", () => {
|
||||
state.activePhotoId = photo.id;
|
||||
rememberOverlayMapView();
|
||||
renderVisiblePhotos({ fitMap: false, preserveMapView: true });
|
||||
openOverlay(photo);
|
||||
openPhotoOverlay(photo);
|
||||
});
|
||||
photoList.appendChild(item);
|
||||
}
|
||||
@@ -723,6 +871,7 @@ function renderVisiblePhotos(options = {}) {
|
||||
|
||||
updateRouteView(visiblePhotos, options);
|
||||
renderTimeline();
|
||||
updateOverlayNavigationState();
|
||||
|
||||
if (options.fitMap && visiblePhotos.length) {
|
||||
const bounds = visiblePhotos.filter((photo) => photo.latitude !== null && photo.longitude !== null);
|
||||
@@ -916,6 +1065,7 @@ timelineClearButton.addEventListener("click", clearTimelineSelection);
|
||||
function appendPhoto(photo) {
|
||||
state.photos.push(photo);
|
||||
scheduleVisiblePhotoRender({ fitMap: state.photos.length === 1 });
|
||||
tryOpenPendingPhoto();
|
||||
}
|
||||
|
||||
async function loadImportListing(shareUrlValue, signal) {
|
||||
@@ -1076,6 +1226,7 @@ stopImportButton.addEventListener("click", () => {
|
||||
});
|
||||
|
||||
const initialShareUrl = getShareUrlFromLocation();
|
||||
pendingOverlayPhotoName = getPhotoNameFromLocation();
|
||||
if (initialShareUrl) {
|
||||
importUrlInput.value = initialShareUrl;
|
||||
window.queueMicrotask(() => {
|
||||
|
||||
@@ -621,6 +621,8 @@ export const APP_STYLES = ` :root {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
color: var(--timeline-text);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.overlay-media {
|
||||
@@ -728,6 +730,41 @@ export const APP_STYLES = ` :root {
|
||||
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 {
|
||||
width: min(210px, 100%);
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -140,21 +140,35 @@ ${APP_STYLES}
|
||||
</section>
|
||||
|
||||
<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">
|
||||
x
|
||||
</button>
|
||||
<div class="overlay-card">
|
||||
<div class="overlay-media">
|
||||
<div class="overlay-card" id="overlay-card">
|
||||
<div class="overlay-media" id="overlay-media">
|
||||
<div class="overlay-loading" id="overlay-loading" aria-live="polite" aria-atomic="true">
|
||||
<span class="spinner active" aria-hidden="true"></span>
|
||||
<strong id="overlay-loading-text">Loading full-size image...</strong>
|
||||
</div>
|
||||
<img id="overlay-image" alt="" />
|
||||
</div>
|
||||
<footer class="overlay-caption">
|
||||
<footer class="overlay-caption" id="overlay-caption">
|
||||
<strong id="overlay-title">Photo</strong>
|
||||
</footer>
|
||||
</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>
|
||||
|
||||
<script src="/vendor/leaflet/leaflet.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user