mirror of
https://github.com/mickael-kerjean/filestash
synced 2025-12-15 21:04:46 +01:00
feature (image): improve image viewer UX
This commit is contained in:
parent
1ffe6b83dc
commit
922285e2c6
8 changed files with 272 additions and 116 deletions
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
175
public/assets/pages/viewerpage/application_image/pagination.js
Normal file
175
public/assets/pages/viewerpage/application_image/pagination.js
Normal 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(),
|
||||
]);
|
||||
}
|
||||
50
public/assets/pages/viewerpage/application_image/zoom.js
Normal file
50
public/assets/pages/viewerpage/application_image/zoom.js
Normal 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");
|
||||
})));
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue