feature (image): improve image viewer UX

This commit is contained in:
MickaelK 2025-05-14 11:01:54 +10:00
parent 1ffe6b83dc
commit 922285e2c6
8 changed files with 272 additions and 116 deletions

View file

@ -14,8 +14,9 @@ import notification from "../../components/notification.js";
import t from "../../locales/index.js";
import ctrlError from "../ctrl_error.js";
import componentMetadata, { init as initMetadata } from "./application_image_metadata.js";
import componentToolbox, { init as initToolbox } from "./application_image_toolbox.js";
import componentInformation, { init as initInformation } from "./application_image/information.js";
import componentPagination, { init as initPagination } from "./application_image/pagination.js";
import componentZoom from "./application_image/zoom.js";
import ctrlDownloader, { init as initDownloader } from "./application_downloader.js";
import { renderMenubar, buttonDownload, buttonFullscreen } from "./component_menubar.js";
@ -29,7 +30,7 @@ export default function(render, { getFilename, getDownloadUrl, mime, hasMenubar
<div class="component_imageviewer">
<component-menubar filename="${getFilename()}" class="${!hasMenubar && "hidden"}"></component-menubar>
<div class="component_image_container">
<div class="images_wrapper">
<div class="images_wrapper no-select">
<img class="photo idle hidden" draggable="false" />
<div data-bind="component_navigation"></div>
</div>
@ -46,7 +47,7 @@ export default function(render, { getFilename, getDownloadUrl, mime, hasMenubar
const load$ = new rxjs.BehaviorSubject(null);
const toggleInfo = () => {
qs($page, ".images_aside").classList.toggle("open");
componentMetadata(createRender(qs($page, ".images_aside")), { toggle: toggleInfo, load$ });
componentInformation(createRender(qs($page, ".images_aside")), { toggle: toggleInfo, load$ });
};
renderMenubar(
@ -70,16 +71,19 @@ export default function(render, { getFilename, getDownloadUrl, mime, hasMenubar
}),
rxjs.tap(() => load$.next($photo)),
removeLoader,
rxjs.tap(() => animate($photo, {
onEnter: () => $photo.classList.remove("hidden"),
time: 300,
easing: "cubic-bezier(.51,.92,.24,1.15)",
keyframes: [
{ opacity: 0, transform: "scale(.97)" },
{ opacity: 1 },
{ opacity: 1, transform: "scale(1)" },
],
})),
rxjs.tap(() => {
const cancel = animate($photo, {
onEnter: () => $photo.classList.remove("hidden"),
onExit: async () => (await cancel)(),
time: 300,
easing: "cubic-bezier(.51,.92,.24,1.15)",
keyframes: [
{ opacity: 0, transform: "scale(.97)" },
{ opacity: 1 },
{ opacity: 1, transform: "scale(1)" },
],
})
}),
rxjs.catchError((err) => {
if (err.target instanceof HTMLElement && err.type === "error") {
return rxjs.of(initDownloader()).pipe(
@ -94,14 +98,21 @@ export default function(render, { getFilename, getDownloadUrl, mime, hasMenubar
return ctrlError()(err);
}),
));
componentToolbox(createRender(qs($page, "[data-bind=\"component_navigation\"]")), { $img: $photo });
effect(load$.pipe(
rxjs.first(),
rxjs.tap(() => {
componentZoom({ $img: $photo, $page, $menubar, load$ });
componentPagination(createRender(qs($page, "[data-bind=\"component_navigation\"]")), { $img: $photo });
}),
));
}
export function init() {
return Promise.all([
loadCSS(import.meta.url, "./application_image.css"),
loadCSS(import.meta.url, "./component_menubar.css"),
initToolbox(), initMetadata(),
initPagination(), initInformation(),
]);
}

View file

@ -1,8 +1,8 @@
import { createElement, createRender } from "../../lib/skeleton/index.js";
import rxjs, { effect, onClick } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import t from "../../locales/index.js";
import { loadJS, loadCSS } from "../../helpers/loader.js";
import { createElement, createRender } from "../../../lib/skeleton/index.js";
import rxjs, { effect, onClick } from "../../../lib/rx.js";
import { qs } from "../../../lib/dom.js";
import t from "../../../locales/index.js";
import { loadJS, loadCSS } from "../../../helpers/loader.js";
export default async function(render, { toggle, load$ }) {
const $page = createElement(`
@ -209,8 +209,8 @@ function componentMore(render, { metadata }) {
export function init() {
return Promise.all([
loadJS(import.meta.url, "../../lib/vendor/exif-js.js"),
loadCSS(import.meta.url, "./application_image_metadata.css"),
loadJS(import.meta.url, "../../../lib/vendor/exif-js.js"),
loadCSS(import.meta.url, "./information.css"),
]);
}

View file

@ -10,8 +10,10 @@
color: #f2f2f2;
z-index: 1;
}
.component_pager.left { left: 0; padding-right: 150px; }
.component_pager.right { right: 0; padding-left: 150px; }
.component_pager.left { left: 0; padding-right: 50px; }
.component_pager.right { right: 0; padding-left: 50px; }
.touch-yes .component_pager.left { display: none; }
.touch-yes .component_pager.right { display: none; }
.component_pager:hover {
opacity: 0.9;
transition: opacity 0.5s ease;

View file

@ -0,0 +1,175 @@
import { createFragment } from "../../../lib/skeleton/index.js";
import rxjs, { effect } from "../../../lib/rx.js";
import { qs } from "../../../lib/dom.js";
import { join } from "../../../lib/path.js";
import { animate, slideXOut } from "../../../lib/animate.js";
import { loadCSS } from "../../../helpers/loader.js";
import { get as getConfig } from "../../../model/config.js";
import { getCurrentPath, getFilename } from "../common.js";
import { getMimeType } from "../mimetype.js";
import fscache from "../../filespage/cache.js";
import { sort } from "../../filespage/helper.js";
import { getState$ as getParams$, init as initParams } from "../../filespage/state_config.js";
export default async function(render, { $img }) {
const lsCache = await fscache().get(join(location, getCurrentPath() + "/../"));
if (!lsCache) return;
const params = await getParams$().pipe(rxjs.first()).toPromise();
const state = {
prev: null,
curr: null,
next: null,
length: 0,
};
const files = filterImages(
sort(lsCache.files, params["sort"], params["order"]),
state,
);
if (state.length <= 1) return;
const $page = createFragment(`
<div class="component_pager left hidden">
<a data-link>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</a>
</div>
<div class="component_pager right hidden">
<a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</a>
</div>
`);
if (state.prev !== null) updateDOM({
$el: $page.children[0],
name: files[state.prev].name,
$img,
});
if (state.next !== null) updateDOM({
$el: $page.children[1],
name: files[state.next].name,
$img,
});
const $navigation = render($page);
initMobileNavigation({ $img, $navigation });
initKeyboardNavigation({ $img, $navigation });
}
function filterImages(files, state) {
const currentFilename = getFilename();
const mimeTypes = getConfig("mime", {});
for (let i=0; i<files.length; i++) {
const filename = files[i].name;
if (!getMimeType(filename, mimeTypes).startsWith("image/")) {
continue;
}
state.length += 1;
if (currentFilename === filename) {
state.curr = i;
} else if (state.curr === null) {
state.prev = i;
} else {
state.next = i;
break;
}
}
return files;
}
function updateDOM({ $el, name, $img }) {
const $link = qs($el, "a");
$link.onclick = async(e) => {
if (e.target.hasAttribute("data-link")) return;
e.preventDefault(); e.stopPropagation();
const sgn = $el.classList.contains("left") ? +1 : -1;
await animate($img, { keyframes: slideXOut(sgn * 10), time: 200 });
$link.setAttribute("data-link", "true");
$link.click();
};
$link.setAttribute("href", "/view" + join(location, getCurrentPath() + "/../" + name));
$el.classList.remove("hidden");
}
function initMobileNavigation({ $img, $navigation }) {
const state = {
active: false,
originX: null,
originT: null,
dist: null,
};
effect(rxjs.fromEvent($img, "touchstart").pipe(rxjs.debounceTime(10), rxjs.tap((event) => {
if (event.touches.length !== 1) return;
event.preventDefault();
$img.style.transition = "0s ease transform";
state.active = true;
state.originT = performance.now();
state.originX = event.touches[0].pageX;
})));
effect(rxjs.fromEvent($img, "touchmove").pipe(rxjs.tap((event) => {
if (event.touches.length !== 1 || state.active === false) return;
event.preventDefault();
state.dist = event.touches[0].pageX - state.originX;
$img.style.transform = `translateX(${state.dist}px)`;
})));
effect(rxjs.fromEvent($img, "touchend").pipe(rxjs.tap(async (event) => {
if (state.active === false) return;
state.active = false;
const shouldTurnPage = ((distPx, elapsedMs, widthPx) => {
const velocity = Math.abs(distPx) / elapsedMs;
const fastEnough = velocity > 1;
const farEnough = Math.abs(distPx) > widthPx * 0.5;
return farEnough || fastEnough;
})(state.dist, performance.now() - state.originT, $img.clientWidth);
if (!shouldTurnPage) {
$img.style.transition = "0.2s ease transform";
$img.style.transform = "";
return;
}
let $navlink = null;
if (state.dist > 0) $navlink = qs($navigation, ".left a");
else $navlink = qs($navigation, ".right a");
if (!$navlink.hasAttribute("href")) {
$img.style.transition = "0.5s ease transform";
$img.style.transform = "";
return;
}
$navlink.click();
await animate($img, { time: 200, keyframes: [
{ transform: `translateX(${state.dist}px)`, opacity: 1 },
{ transform: `translateX(${$img.clientWidth*Math.sign(state.dist)}px)`, opacity: 0 },
]});
$img.classList.add("hidden");
})));
}
function initKeyboardNavigation({ $img, $navigation }) {
effect(rxjs.fromEvent(window, "keydown").pipe(rxjs.tap(({ key }) => {
let $navlink = null;
switch (key) {
case "ArrowLeft":
$navlink = qs($navigation, ".left a");
break;
case "ArrowRight":
$navlink = qs($navigation, ".right a");
break;
}
if (!$navlink || !$navlink.hasAttribute("href")) return;
$navlink.click();
})));
}
export function init() {
return Promise.all([
loadCSS(import.meta.url, "./pagination.css"),
initParams(),
]);
}

View file

@ -0,0 +1,50 @@
import { createElement } from "../../../lib/skeleton/index.js";
import rxjs, { effect } from "../../../lib/rx.js";
import { qs } from "../../../lib/dom.js";
import { animate, opacityOut } from "../../../lib/animate.js";
import { loadJS } from "../../../helpers/loader.js";
export default function ({ $img, $page, $menubar }) {
const $navigation = qs($page, `[data-bind="component_navigation"]`);
const state = {
active: false,
x: null,
y: null,
distance: null,
};
const distance = ({ touches }) => Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
effect(rxjs.fromEvent($img.parentElement, "touchstart").pipe(rxjs.tap((event) => {
if (event.touches.length < 2) return;
state.active = true;
event.preventDefault();
$img.style.transition = "0.1s ease transform";
$img.style.transformOrigin = "50% 50%";
$navigation.classList.add("hidden");
state.x = (event.touches[0].pageX + event.touches[1].pageX) / 2;
state.y = (event.touches[0].pageY + event.touches[1].pageY) / 2;
state.distance = distance(event);
})));
effect(rxjs.fromEvent($img.parentElement, "touchmove").pipe(rxjs.tap((event) => {
if (event.touches.length < 2) return;
event.preventDefault();
let scale = distance(event) / state.distance;
if (scale < 1) scale = 1;
else if (scale > 20) scale = 20;
const deltaX = (((event.touches[0].pageX + event.touches[1].pageX) / 2) - state.x) * 2;
const deltaY = (((event.touches[0].pageY + event.touches[1].pageY) / 2) - state.y) * 2;
$img.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) scale(${scale})`;
})));
effect(rxjs.fromEvent($img.parentElement, "touchend").pipe(rxjs.tap((event) => {
if (state.active === false) return;
state.active = true;
$img.style.transition = "0.3s ease transform";
$img.style.transform = "";
$navigation.classList.remove("hidden");
})));
}

View file

@ -1,91 +0,0 @@
import { createFragment } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import { join } from "../../lib/path.js";
import { animate, slideXOut } from "../../lib/animate.js";
import { loadCSS } from "../../helpers/loader.js";
import { get as getConfig } from "../../model/config.js";
import { getCurrentPath, getFilename } from "./common.js";
import { getMimeType } from "./mimetype.js";
import fscache from "../filespage/cache.js";
import { sort } from "../filespage/helper.js";
import { getState$ as getParams$, init as initParams } from "../filespage/state_config.js";
export default async function(render, { $img }) {
const lsCache = await fscache().get(join(location, getCurrentPath() + "/../"));
if (!lsCache) return;
const params = await getParams$().pipe(rxjs.first()).toPromise();
const files = sort(lsCache.files, params["sort"], params["order"]);
const currentFilename = getFilename();
const mimeTypes = getConfig("mime", {});
const state = {
prev: null,
curr: null,
next: null,
length: 0,
};
for (let i=0; i<files.length; i++) {
const filename = files[i].name;
if (!getMimeType(filename, mimeTypes).startsWith("image/")) {
continue;
}
state.length += 1;
if (currentFilename === filename) {
state.curr = i;
} else if (state.curr === null) {
state.prev = i;
} else {
state.next = i;
break;
}
}
if (state.length <= 1) return;
const $page = createFragment(`
<div class="component_pager left hidden">
<a data-link>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
</a>
</div>
<div class="component_pager right hidden">
<a>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</a>
</div>
`);
if (state.prev !== null) updateDOM({
$el: $page.children[0],
name: files[state.prev].name,
$img,
});
if (state.next !== null) updateDOM({
$el: $page.children[1],
name: files[state.next].name,
$img,
});
render($page);
}
function updateDOM({ $el, name, $img }) {
const $link = qs($el, "a");
$link.onclick = async(e) => {
if (e.target.hasAttribute("data-link")) return;
e.preventDefault(); e.stopPropagation();
await animate($img, { keyframes: slideXOut(-10), time: 200 });
$link.setAttribute("data-link", "true");
$link.click();
};
$link.setAttribute("href", "/view" + join(location, getCurrentPath() + "/../" + name));
$el.classList.remove("hidden");
}
export function init() {
return Promise.all([
loadCSS(import.meta.url, "./application_image_toolbox.css"),
initParams(),
]);
}

View file

@ -38,6 +38,15 @@
.component_menubar .action-item .download-button .component_icon {
padding-right: 1px;
}
.component_menubar .action-item .btn {
background: #e2e2e2;
color: rgba(0,0,0,0.6);
padding: 0 5px;
border-radius: 2px;
cursor: pointer;
text-transform: uppercase;
font-weight: bold;
}
.dark-mode .component_menubar {
background: var(--bg-color);