From 00510820a2794efcadbc83f7f8b54318fe198ecb Mon Sep 17 00:00:00 2001
From: Zéro~Informatique
Date: Tue, 26 Jul 2022 08:44:34 +0200
Subject: viewer: migrate to vue 3, general refactoring and cleanup
Non-exhaustive list of fixes and improvements done at the same time:
- html default background to grey (avoids white flash during init)
- unified links behavior
- added more theme variables
- removed the flex-expand transition (it wasn't working) and replaced it
with a slide
- fixed LdLoading not centered on the content
- title on removable tags
- fixed an issue with encoded URI from vue-router
- unified Item resource URLs
- removed the iframe for PlainTextViewer (it wasn't working properly)
and replaced it with a pre
- fixed clear and search buttons tabindex
- fixed the information panel bumping up during the fade animation of
tag's dropdown
- fixed some focus outlines not appearing correctly
- moved CSS variables to the :root context
- Code cleaning
GitHub: closes #217
GitHub: closes #300
GitHub: closes #297
GitHub: closes #105
GitHub: closes #267
GitHub: closes #275
GitHub: closes #228
GitHub: closes #215
GitHub: closes #112
---
viewer/src/services/api/ldFetch.ts | 35 ++++++
viewer/src/services/dragscrollclickfix.ts | 51 ---------
viewer/src/services/fetchWithCheck.ts | 7 --
viewer/src/services/indexFactory.ts | 163 ++++++++++++++++++++++++++++
viewer/src/services/indexSearch.ts | 74 +++++++++++++
viewer/src/services/indexfactory.ts | 157 ---------------------------
viewer/src/services/indexsearch.ts | 70 ------------
viewer/src/services/itemComparator.ts | 93 ++++++++++++++++
viewer/src/services/itemComparators.ts | 74 -------------
viewer/src/services/itemGuards.ts | 11 ++
viewer/src/services/ldzoom.ts | 135 -----------------------
viewer/src/services/navigation.ts | 90 ++++++++-------
viewer/src/services/ui/ldFullscreen.ts | 41 +++++++
viewer/src/services/ui/ldItemResourceUrl.ts | 15 +++
viewer/src/services/ui/ldKeepFocus.ts | 34 ++++++
viewer/src/services/ui/ldKeyboard.ts | 28 +++++
viewer/src/services/ui/ldSaveScroll.ts | 37 +++++++
viewer/src/services/ui/ldTitle.ts | 34 ++++++
viewer/src/services/ui/ldZoom.ts | 128 ++++++++++++++++++++++
19 files changed, 736 insertions(+), 541 deletions(-)
create mode 100644 viewer/src/services/api/ldFetch.ts
delete mode 100644 viewer/src/services/dragscrollclickfix.ts
delete mode 100644 viewer/src/services/fetchWithCheck.ts
create mode 100644 viewer/src/services/indexFactory.ts
create mode 100644 viewer/src/services/indexSearch.ts
delete mode 100644 viewer/src/services/indexfactory.ts
delete mode 100644 viewer/src/services/indexsearch.ts
create mode 100644 viewer/src/services/itemComparator.ts
delete mode 100644 viewer/src/services/itemComparators.ts
create mode 100644 viewer/src/services/itemGuards.ts
delete mode 100644 viewer/src/services/ldzoom.ts
create mode 100644 viewer/src/services/ui/ldFullscreen.ts
create mode 100644 viewer/src/services/ui/ldItemResourceUrl.ts
create mode 100644 viewer/src/services/ui/ldKeepFocus.ts
create mode 100644 viewer/src/services/ui/ldKeyboard.ts
create mode 100644 viewer/src/services/ui/ldSaveScroll.ts
create mode 100644 viewer/src/services/ui/ldTitle.ts
create mode 100644 viewer/src/services/ui/ldZoom.ts
(limited to 'viewer/src/services')
diff --git a/viewer/src/services/api/ldFetch.ts b/viewer/src/services/api/ldFetch.ts
new file mode 100644
index 0000000..4d2f346
--- /dev/null
+++ b/viewer/src/services/api/ldFetch.ts
@@ -0,0 +1,35 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { MaybeComputedRef, useFetch } from '@vueuse/core';
+import { createToast } from 'mosha-vue-toastify';
+
+export const useLdFetch = (url: MaybeComputedRef) => {
+ const fetchReturn = useFetch(url, { refetch: true });
+ fetchReturn.onFetchError((error) => {
+ createToast(String(error), {
+ type: 'danger',
+ position: 'top-center',
+ timeout: 10000,
+ showIcon: true,
+ onClose: fetchReturn.execute,
+ });
+ });
+ return fetchReturn;
+};
diff --git a/viewer/src/services/dragscrollclickfix.ts b/viewer/src/services/dragscrollclickfix.ts
deleted file mode 100644
index 7125510..0000000
--- a/viewer/src/services/dragscrollclickfix.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-/* ldgallery - A static generator which turns a collection of tagged
--- pictures into a searchable web gallery.
---
--- Copyright (C) 2019-2020 Guillaume FOUET
---
--- This program is free software: you can redistribute it and/or modify
--- it under the terms of the GNU Affero General Public License as
--- published by the Free Software Foundation, either version 3 of the
--- License, or (at your option) any later version.
---
--- This program 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 Affero General Public License for more details.
---
--- You should have received a copy of the GNU Affero General Public License
--- along with this program. If not, see .
-*/
-
-// https://github.com/donmbelembe/vue-dragscroll/issues/61
-export default class DragScrollClickFix {
- readonly DRAG_DELAY = 250; // This is the minimal delay to consider a click to be a drag, mostly usefull for touch devices
-
- timer: NodeJS.Timeout | null = null;
- dragging: boolean = false;
-
- onDragScrollStart() {
- this.timer = setTimeout(() => this.onTimer(), this.DRAG_DELAY);
- }
-
- onTimer() {
- this.timer = null;
- this.dragging = true;
- }
-
- onDragScrollEnd() {
- if (this.timer) {
- clearTimeout(this.timer);
- this.timer = null;
- }
- setTimeout(() => (this.dragging = false));
- }
-
- onClickCapture(e: MouseEvent) {
- if (this.dragging) {
- this.dragging = false;
- e.preventDefault();
- e.stopPropagation();
- }
- }
-}
diff --git a/viewer/src/services/fetchWithCheck.ts b/viewer/src/services/fetchWithCheck.ts
deleted file mode 100644
index e84e8b6..0000000
--- a/viewer/src/services/fetchWithCheck.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export default class FetchWithCheck {
- static async get(url: RequestInfo): Promise {
- const response = await fetch(url);
- if (!response.ok) throw new Error(`${response.status}: ${response.statusText}`);
- return response;
- }
-}
diff --git a/viewer/src/services/indexFactory.ts b/viewer/src/services/indexFactory.ts
new file mode 100644
index 0000000..a414856
--- /dev/null
+++ b/viewer/src/services/indexFactory.ts
@@ -0,0 +1,163 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { Item, RawTag } from '@/@types/gallery';
+import { Operation } from '@/@types/operation';
+import { TagCategory, TagIndex, TagNode, TagSearch } from '@/@types/tag';
+import { isDirectory } from './itemGuards';
+import { useNavigation } from './navigation';
+
+const navigation = useNavigation();
+
+function _pushPartToIndex(index: TagNode, part: string, item: Item, rootPart: boolean): TagNode {
+ if (!index) {
+ index = {
+ tag: part,
+ tagfiltered: navigation.normalize(part),
+ rootPart,
+ childPart: !rootPart,
+ items: [],
+ children: {},
+ };
+ } else if (rootPart) index.rootPart = true;
+ else index.childPart = true;
+
+ if (!index.items.includes(item)) index.items.push(item);
+ return index;
+}
+
+// Pushes all tags for a root item (and its children) to the index
+function _pushTagsForItem(tagsIndex: TagIndex, item: Item): void {
+ if (isDirectory(item)) {
+ item.properties.items.forEach(item => _pushTagsForItem(tagsIndex, item));
+ return; // Directories are not indexed
+ }
+ for (const tag of item.tags) {
+ const parts = tag.split(':');
+ let lastPart: string | null = null;
+ for (const part of parts) {
+ tagsIndex[part] = _pushPartToIndex(tagsIndex[part], part, item, !lastPart);
+ if (lastPart) {
+ const children = tagsIndex[lastPart].children;
+ children[part] = _pushPartToIndex(children[part], part, item, false);
+ }
+ lastPart = part;
+ }
+ if (lastPart) tagsIndex[lastPart].childPart = true;
+ }
+}
+
+function _extractOperation(filter: string): Operation {
+ const first = filter.slice(0, 1);
+ switch (first) {
+ case Operation.ADDITION:
+ case Operation.SUBSTRACTION:
+ return first;
+ default:
+ return Operation.INTERSECTION;
+ }
+}
+
+function _searchTagsFromFilterWithCategory(
+ tagsIndex: TagIndex,
+ operation: Operation,
+ category: string,
+ disambiguation: string,
+ strict: boolean,
+): TagSearch[] {
+ category = navigation.normalize(category);
+ disambiguation = navigation.normalize(disambiguation);
+ return Object.values(tagsIndex)
+ .filter(node => _matches(node, category, strict))
+ .flatMap(node =>
+ Object.values(node.children)
+ .filter(child => _matches(child, disambiguation, strict))
+ .map(child => ({ ...child, parent: node, operation, display: `${operation}${node.tag}:${child.tag}` })),
+ );
+}
+
+function _searchTagsFromFilter(
+ tagsIndex: TagIndex,
+ operation: Operation,
+ filter: string,
+ strict: boolean,
+): TagSearch[] {
+ filter = navigation.normalize(filter);
+ return Object.values(tagsIndex)
+ .filter(node => _matches(node, filter, strict))
+ .map(node => ({ ...node, operation, display: `${operation}${node.tag}` }));
+}
+
+function _matches(node: TagNode, filter: string, strict: boolean): boolean {
+ if (strict) return node.tagfiltered === filter;
+ return node.tagfiltered.includes(filter);
+}
+
+function _isDiscriminantTagOnly(tags: RawTag[], node: TagNode): boolean {
+ return !tags.includes(node.tag) || !node.childPart;
+}
+
+// ---
+
+export const useIndexFactory = () => {
+ function generateTags(root: Item | null): TagIndex {
+ const tagsIndex: TagIndex = {};
+ if (root) _pushTagsForItem(tagsIndex, root);
+ return tagsIndex;
+ }
+
+ function searchTags(tagsIndex: TagIndex, filter: string, strict: boolean): TagSearch[] {
+ let search: TagSearch[] = [];
+ if (tagsIndex && filter) {
+ const operation = _extractOperation(filter);
+ if (operation !== Operation.INTERSECTION) filter = filter.slice(1);
+ if (filter.includes(':')) {
+ const filterParts = filter.split(':');
+ search = _searchTagsFromFilterWithCategory(tagsIndex, operation, filterParts[0], filterParts[1], strict);
+ } else {
+ search = _searchTagsFromFilter(tagsIndex, operation, filter, strict);
+ }
+ }
+ return search;
+ }
+
+ function generateCategories(tagsIndex: TagIndex, categoryTags?: RawTag[]): TagCategory[] {
+ if (!categoryTags?.length) return [{ tag: '', index: tagsIndex }];
+
+ const tagsCategories: TagCategory[] = [];
+ const tagsRemaining = new Map(Object.entries(tagsIndex));
+ categoryTags
+ .map(tag => ({ tag, index: tagsIndex[tag]?.children }))
+ .filter(category => category.index && Object.keys(category.index).length)
+ .forEach(category => {
+ tagsCategories.push(category);
+ [category.tag, ...Object.values(category.index).map(node => node.tag)]
+ .filter(tag => _isDiscriminantTagOnly(categoryTags, tagsIndex[tag]))
+ .forEach(tag => tagsRemaining.delete(tag));
+ });
+ tagsCategories.push({ tag: '', index: Object.fromEntries(tagsRemaining) });
+ return tagsCategories;
+ }
+
+ return {
+ generateTags,
+ searchTags,
+ generateCategories,
+ };
+};
diff --git a/viewer/src/services/indexSearch.ts b/viewer/src/services/indexSearch.ts
new file mode 100644
index 0000000..df0a600
--- /dev/null
+++ b/viewer/src/services/indexSearch.ts
@@ -0,0 +1,74 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { Item } from '@/@types/gallery';
+import { Operation } from '@/@types/operation';
+import { TagSearch, TagSearchByOperation } from '@/@types/tag';
+
+function _extractTagsByOperation(searchTags: TagSearch[]): TagSearchByOperation {
+ const byOperation: TagSearchByOperation = {};
+ Object.values(Operation).forEach(
+ operation => (byOperation[operation] = searchTags.filter(tag => tag.operation === operation)),
+ );
+ return byOperation;
+}
+
+function _extractIntersection(byOperation: TagSearchByOperation): Set- {
+ const intersection = new Set
- ();
+ if (byOperation[Operation.INTERSECTION].length > 0) {
+ byOperation[Operation.INTERSECTION]
+ .map(tag => tag.items)
+ .reduce((a, b) => a.filter(c => b.includes(c)))
+ .flatMap(items => items)
+ .forEach(item => intersection.add(item));
+ }
+ return intersection;
+}
+
+function _extractSubstraction(byOperation: TagSearchByOperation): Set
- {
+ const substraction = new Set
- ();
+ if (byOperation[Operation.SUBSTRACTION].length > 0) {
+ byOperation[Operation.SUBSTRACTION].flatMap(tag => tag.items).forEach(item => substraction.add(item));
+ }
+ return substraction;
+}
+
+function _aggregateAll(
+ byOperation: TagSearchByOperation,
+ intersection: Set
- ,
+ substraction: Set
- ,
+): Item[] {
+ byOperation[Operation.ADDITION].flatMap(tag => tag.items).forEach(item => intersection.add(item));
+ substraction.forEach(item => intersection.delete(item));
+ return [...intersection];
+}
+
+// ---
+
+export const useIndexSearch = () => {
+ // Results of the search (by tags)
+ function indexSearch(searchTags: TagSearch[]): Item[] {
+ const byOperation = _extractTagsByOperation(searchTags);
+ const intersection = _extractIntersection(byOperation);
+ const substraction = _extractSubstraction(byOperation);
+ return _aggregateAll(byOperation, intersection, substraction);
+ }
+
+ return indexSearch;
+};
diff --git a/viewer/src/services/indexfactory.ts b/viewer/src/services/indexfactory.ts
deleted file mode 100644
index 691a765..0000000
--- a/viewer/src/services/indexfactory.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-/* ldgallery - A static generator which turns a collection of tagged
--- pictures into a searchable web gallery.
---
--- Copyright (C) 2019-2020 Guillaume FOUET
---
--- This program is free software: you can redistribute it and/or modify
--- it under the terms of the GNU Affero General Public License as
--- published by the Free Software Foundation, either version 3 of the
--- License, or (at your option) any later version.
---
--- This program 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 Affero General Public License for more details.
---
--- You should have received a copy of the GNU Affero General Public License
--- along with this program. If not, see .
-*/
-
-import { Item, RawTag } from "@/@types/gallery";
-import { ItemType } from "@/@types/ItemType";
-import { Operation } from "@/@types/Operation";
-import { TagCategory, TagIndex, TagNode, TagSearch } from "@/@types/tag";
-import Navigation from "@/services/navigation";
-
-export default class IndexFactory {
- public static generateTags(root: Item | null): TagIndex {
- const tagsIndex: TagIndex = {};
- if (root) IndexFactory.pushTagsForItem(tagsIndex, root);
- return tagsIndex;
- }
-
- // Pushes all tags for a root item (and its children) to the index
- private static pushTagsForItem(tagsIndex: TagIndex, item: Item): void {
- if (item.properties.type === ItemType.DIRECTORY) {
- item.properties.items.forEach(item => this.pushTagsForItem(tagsIndex, item));
- return; // Directories are not indexed
- }
- for (const tag of item.tags) {
- const parts = tag.split(":");
- let lastPart: string | null = null;
- for (const part of parts) {
- tagsIndex[part] = IndexFactory.pushPartToIndex(tagsIndex[part], part, item, !Boolean(lastPart));
- if (lastPart) {
- const children = tagsIndex[lastPart].children;
- children[part] = IndexFactory.pushPartToIndex(children[part], part, item, false);
- }
- lastPart = part;
- }
- if (lastPart) tagsIndex[lastPart].childPart = true;
- }
- }
-
- private static pushPartToIndex(index: TagNode, part: string, item: Item, rootPart: boolean): TagNode {
- if (!index)
- index = {
- tag: part,
- tagfiltered: Navigation.normalize(part),
- rootPart,
- childPart: !rootPart,
- items: [],
- children: {},
- };
- else if (rootPart) index.rootPart = true;
- else index.childPart = true;
-
- if (!index.items.includes(item)) index.items.push(item);
- return index;
- }
-
- // ---
-
- public static searchTags(tagsIndex: TagIndex, filter: string, strict: boolean): TagSearch[] {
- let search: TagSearch[] = [];
- if (tagsIndex && filter) {
- const operation = IndexFactory.extractOperation(filter);
- if (operation !== Operation.INTERSECTION) filter = filter.slice(1);
- if (filter.includes(":")) {
- const filterParts = filter.split(":");
- search = this.searchTagsFromFilterWithCategory(tagsIndex, operation, filterParts[0], filterParts[1], strict);
- } else {
- search = this.searchTagsFromFilter(tagsIndex, operation, filter, strict);
- }
- }
- return search;
- }
-
- private static extractOperation(filter: string): Operation {
- const first = filter.slice(0, 1);
- switch (first) {
- case Operation.ADDITION:
- case Operation.SUBSTRACTION:
- return first;
- default:
- return Operation.INTERSECTION;
- }
- }
-
- private static searchTagsFromFilterWithCategory(
- tagsIndex: TagIndex,
- operation: Operation,
- category: string,
- disambiguation: string,
- strict: boolean
- ): TagSearch[] {
- category = Navigation.normalize(category);
- disambiguation = Navigation.normalize(disambiguation);
- return Object.values(tagsIndex)
- .filter(node => IndexFactory.matches(node, category, strict))
- .flatMap(node =>
- Object.values(node.children)
- .filter(child => IndexFactory.matches(child, disambiguation, strict))
- .map(child => ({ ...child, parent: node, operation, display: `${operation}${node.tag}:${child.tag}` }))
- );
- }
-
- private static searchTagsFromFilter(
- tagsIndex: TagIndex,
- operation: Operation,
- filter: string,
- strict: boolean
- ): TagSearch[] {
- filter = Navigation.normalize(filter);
- return Object.values(tagsIndex)
- .filter(node => IndexFactory.matches(node, filter, strict))
- .map(node => ({ ...node, operation, display: `${operation}${node.tag}` }));
- }
-
- private static matches(node: TagNode, filter: string, strict: boolean): boolean {
- if (strict) return node.tagfiltered === filter;
- return node.tagfiltered.includes(filter);
- }
-
- // ---
-
- public static generateCategories(tagsIndex: TagIndex, categoryTags?: RawTag[]): TagCategory[] {
- if (!categoryTags?.length) return [{ tag: "", index: tagsIndex }];
-
- const tagsCategories: TagCategory[] = [];
- const tagsRemaining = new Map(Object.entries(tagsIndex));
- categoryTags
- .map(tag => ({ tag, index: tagsIndex[tag]?.children }))
- .filter(category => category.index && Object.keys(category.index).length)
- .forEach(category => {
- tagsCategories.push(category);
- [category.tag, ...Object.values(category.index).map(node => node.tag)]
- .filter(tag => IndexFactory.isDiscriminantTagOnly(categoryTags, tagsIndex[tag]))
- .forEach(tag => tagsRemaining.delete(tag));
- });
- tagsCategories.push({ tag: "", index: Object.fromEntries(tagsRemaining) });
- return tagsCategories;
- }
-
- private static isDiscriminantTagOnly(tags: RawTag[], node: TagNode): boolean {
- return !tags.includes(node.tag) || !node.childPart;
- }
-}
diff --git a/viewer/src/services/indexsearch.ts b/viewer/src/services/indexsearch.ts
deleted file mode 100644
index 57bd03c..0000000
--- a/viewer/src/services/indexsearch.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/* ldgallery - A static generator which turns a collection of tagged
--- pictures into a searchable web gallery.
---
--- Copyright (C) 2019-2020 Guillaume FOUET
---
--- This program is free software: you can redistribute it and/or modify
--- it under the terms of the GNU Affero General Public License as
--- published by the Free Software Foundation, either version 3 of the
--- License, or (at your option) any later version.
---
--- This program 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 Affero General Public License for more details.
---
--- You should have received a copy of the GNU Affero General Public License
--- along with this program. If not, see .
-*/
-
-import { Item } from "@/@types/gallery";
-import { Operation } from "@/@types/Operation";
-import { TagSearch, TagSearchByOperation } from "@/@types/tag";
-
-export default class IndexSearch {
- // Results of the search (by tags)
- public static search(searchTags: TagSearch[]): Item[] {
- const byOperation = this.extractTagsByOperation(searchTags);
- const intersection = this.extractIntersection(byOperation);
- const substraction = this.extractSubstraction(byOperation);
- return this.aggregateAll(byOperation, intersection, substraction);
- }
-
- private static extractTagsByOperation(searchTags: TagSearch[]): TagSearchByOperation {
- const byOperation: TagSearchByOperation = {};
- Object.values(Operation).forEach(
- operation => (byOperation[operation] = searchTags.filter(tag => tag.operation === operation))
- );
- return byOperation;
- }
-
- private static extractIntersection(byOperation: TagSearchByOperation): Set
- {
- const intersection = new Set
- ();
- if (byOperation[Operation.INTERSECTION].length > 0) {
- byOperation[Operation.INTERSECTION]
- .map(tag => tag.items)
- .reduce((a, b) => a.filter(c => b.includes(c)))
- .flatMap(items => items)
- .forEach(item => intersection.add(item));
- }
- return intersection;
- }
-
- private static extractSubstraction(byOperation: TagSearchByOperation): Set
- {
- const substraction = new Set
- ();
- if (byOperation[Operation.SUBSTRACTION].length > 0) {
- byOperation[Operation.SUBSTRACTION].flatMap(tag => tag.items).forEach(item => substraction.add(item));
- }
- return substraction;
- }
-
- private static aggregateAll(
- byOperation: TagSearchByOperation,
- intersection: Set
- ,
- substraction: Set
-
- ): Item[] {
- byOperation[Operation.ADDITION].flatMap(tag => tag.items).forEach(item => intersection.add(item));
- substraction.forEach(item => intersection.delete(item));
- return [...intersection];
- }
-}
diff --git a/viewer/src/services/itemComparator.ts b/viewer/src/services/itemComparator.ts
new file mode 100644
index 0000000..25010b8
--- /dev/null
+++ b/viewer/src/services/itemComparator.ts
@@ -0,0 +1,93 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { Item, ItemSortStr } from '@/@types/gallery';
+import i18n from '@/plugins/i18n';
+import { isDirectory } from './itemGuards';
+
+const { t } = i18n.global;
+
+export type ItemComparator = (left: Item, right: Item) => number;
+export type ItemSort = { name: ItemSortStr, text: string; fn: ItemComparator };
+
+function _sortByPathAsc(left: Item, right: Item): number {
+ return left.path.localeCompare(right.path, undefined, {
+ sensitivity: 'base',
+ ignorePunctuation: true,
+ numeric: true,
+ });
+}
+
+function _sortByTitleAsc(left: Item, right: Item): number {
+ return left.title.localeCompare(right.title, undefined, {
+ sensitivity: 'base',
+ ignorePunctuation: true,
+ numeric: true,
+ });
+}
+
+function _sortByDateAsc(left: Item, right: Item): number {
+ return left.datetime.localeCompare(right.datetime); // TODO: handle timezones
+}
+
+function _sortDirectoryFirst(left: Item, right: Item): number {
+ const dLeft = isDirectory(left) ? 1 : 0;
+ const dRight = isDirectory(right) ? 1 : 0;
+ return dRight - dLeft;
+}
+
+function _reverse(fn: ItemComparator): ItemComparator {
+ return (l, r) => -fn(l, r);
+}
+
+function _chain(comparators: ItemComparator[]): ItemComparator {
+ return comparators.reduce((primary, tieBreaker) => (l, r) => {
+ const primaryComparison = primary(l, r);
+ return primaryComparison !== 0 ? primaryComparison : tieBreaker(l, r);
+ });
+}
+
+// ---
+
+export const useItemComparator = () => {
+ const ITEM_SORTS: ItemSort[] = [
+ {
+ name: 'title_asc',
+ text: t('command.sort.byTitleAsc'),
+ fn: _chain([_sortDirectoryFirst, _sortByTitleAsc, _sortByPathAsc]),
+ },
+ {
+ name: 'date_asc',
+ text: t('command.sort.byDateAsc'),
+ fn: _chain([_sortDirectoryFirst, _sortByDateAsc, _sortByPathAsc]),
+ },
+ {
+ name: 'date_desc',
+ text: t('command.sort.byDateDesc'),
+ fn: _chain([_sortDirectoryFirst, _reverse(_sortByDateAsc), _sortByPathAsc]),
+ },
+ ];
+
+ const DEFAULT = ITEM_SORTS[2]; // date_desc
+
+ return {
+ ITEM_SORTS,
+ DEFAULT,
+ };
+};
diff --git a/viewer/src/services/itemComparators.ts b/viewer/src/services/itemComparators.ts
deleted file mode 100644
index aceff79..0000000
--- a/viewer/src/services/itemComparators.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-/* ldgallery - A static generator which turns a collection of tagged
--- pictures into a searchable web gallery.
---
--- Copyright (C) 2019-2020 Guillaume FOUET
---
--- This program is free software: you can redistribute it and/or modify
--- it under the terms of the GNU Affero General Public License as
--- published by the Free Software Foundation, either version 3 of the
--- License, or (at your option) any later version.
---
--- This program 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 Affero General Public License for more details.
---
--- You should have received a copy of the GNU Affero General Public License
--- along with this program. If not, see .
-*/
-import { Item, ItemSortStr } from "@/@types/gallery";
-import i18n from "@/plugins/i18n";
-import { TranslateResult } from "vue-i18n";
-
-export type ItemComparator = (left: Item, right: Item) => number;
-export type ItemSort = { text: TranslateResult; fn: ItemComparator };
-
-export default class ItemComparators {
- static readonly ITEM_SORTS: Record = {
- title_asc: {
- text: i18n.t("command.sort.byTitleAsc"),
- fn: ItemComparators.chain(ItemComparators.sortByTitleAsc, ItemComparators.sortByPathAsc),
- },
- date_asc: {
- text: i18n.t("command.sort.byDateAsc"),
- fn: ItemComparators.chain(ItemComparators.sortByDateAsc, ItemComparators.sortByPathAsc),
- },
- date_desc: {
- text: i18n.t("command.sort.byDateDesc"),
- fn: ItemComparators.reverse(ItemComparators.chain(ItemComparators.sortByDateAsc, ItemComparators.sortByPathAsc)),
- },
- };
-
- static readonly DEFAULT = ItemComparators.ITEM_SORTS.date_asc;
-
- static sortByPathAsc(left: Item, right: Item): number {
- return left.path.localeCompare(right.path, undefined, {
- sensitivity: "base",
- ignorePunctuation: true,
- numeric: true,
- });
- }
-
- static sortByTitleAsc(left: Item, right: Item): number {
- return left.title.localeCompare(right.title, undefined, {
- sensitivity: "base",
- ignorePunctuation: true,
- numeric: true,
- });
- }
-
- static sortByDateAsc(left: Item, right: Item): number {
- return left.datetime.localeCompare(right.datetime); // TODO: handle timezones
- }
-
- static reverse(fn: ItemComparator): ItemComparator {
- return (l, r) => -fn(l, r);
- }
-
- static chain(primary: ItemComparator, tieBreaker: ItemComparator): ItemComparator {
- return (l, r) => {
- const primaryComparison = primary(l, r);
- return primaryComparison !== 0 ? primaryComparison : tieBreaker(l, r);
- };
- }
-}
diff --git a/viewer/src/services/itemGuards.ts b/viewer/src/services/itemGuards.ts
new file mode 100644
index 0000000..114c2d9
--- /dev/null
+++ b/viewer/src/services/itemGuards.ts
@@ -0,0 +1,11 @@
+import { DirectoryItem, DownloadableItem, Item } from '@/@types/gallery';
+import { ItemType } from '@/@types/itemType';
+
+export function isDirectory(item: Item | null): item is DirectoryItem {
+ return item?.properties.type === ItemType.DIRECTORY;
+}
+
+export function isDownloadableItem(item: Item | null): item is DownloadableItem {
+ if (!item?.properties) return false;
+ return 'resource' in item.properties;
+}
diff --git a/viewer/src/services/ldzoom.ts b/viewer/src/services/ldzoom.ts
deleted file mode 100644
index 33a64c8..0000000
--- a/viewer/src/services/ldzoom.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/* ldgallery - A static generator which turns a collection of tagged
--- pictures into a searchable web gallery.
---
--- Copyright (C) 2020 Pacien TRAN-GIRARD
---
--- This program is free software: you can redistribute it and/or modify
--- it under the terms of the GNU Affero General Public License as
--- published by the Free Software Foundation, either version 3 of the
--- License, or (at your option) any later version.
---
--- This program 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 Affero General Public License for more details.
---
--- You should have received a copy of the GNU Affero General Public License
--- along with this program. If not, see .
-*/
-
-import { PictureProperties, Resolution } from "@/@types/gallery";
-import "hammerjs";
-
-/**
- * Mousewheel and pinch zoom handler.
- */
-export default class LdZoom {
- readonly containerElement: HTMLDivElement;
- readonly imageElement: HTMLImageElement;
- readonly pictureProperties: PictureProperties;
- readonly maxScaleFactor: number;
- readonly scrollZoomSpeed: number;
- scaleFactor: number = 0.0;
-
- constructor(
- containerElement: HTMLDivElement,
- imageElement: HTMLImageElement,
- pictureProperties: PictureProperties,
- maxScaleFactor: number,
- scrollZoomSpeed: number
- ) {
- this.containerElement = containerElement;
- this.imageElement = imageElement;
- this.pictureProperties = pictureProperties;
- this.maxScaleFactor = maxScaleFactor;
- this.scrollZoomSpeed = scrollZoomSpeed;
- }
-
- /**
- * Register event listeners.
- */
- public install() {
- this.updateImageScale(this.scaleFactor);
-
- new ResizeObserver(() => {
- this.updateImageScale(this.scaleFactor);
- }).observe(this.containerElement);
-
- this.containerElement.addEventListener("wheel", wheelEvent => {
- wheelEvent.preventDefault();
- const scaleFactor = this.scaleFactor - Math.sign(wheelEvent.deltaY) * this.scrollZoomSpeed;
- this.zoom(wheelEvent.offsetX, wheelEvent.offsetY, scaleFactor);
- });
-
- const pinchListener = new Hammer(this.containerElement);
- pinchListener.get("pinch").set({ enable: true });
- this.installPinchHandler(pinchListener);
- }
-
- private installPinchHandler(pinchListener: HammerManager) {
- let startScaleFactor = 0.0;
-
- pinchListener.on("pinchstart", (pinchEvent: HammerInput) => {
- startScaleFactor = this.scaleFactor;
- });
-
- pinchListener.on("pinchmove", (pinchEvent: HammerInput) => {
- const focusX = pinchEvent.center.x + this.containerElement.scrollLeft;
- const focusY = pinchEvent.center.y + this.containerElement.scrollTop;
- const scaleFactor = pinchEvent.scale * startScaleFactor;
- this.zoom(focusX, focusY, scaleFactor);
- });
- }
-
- /**
- * Returns the picture resolution as it should be displayed.
- */
- private getDisplayResolution(): Resolution {
- return {
- width: this.pictureProperties.resolution.width * this.scaleFactor,
- height: this.pictureProperties.resolution.height * this.scaleFactor,
- };
- }
-
- /**
- * Applies scaling to the DOM image element.
- * To call after internal intermediate computations because DOM properties aren't stable.
- */
- private resizeImageElement() {
- const imageDim = this.getDisplayResolution();
- this.imageElement.width = imageDim.width;
- this.imageElement.height = imageDim.height;
- }
-
- /**
- * Centers the image element inside its container if it fits, or stick to the top and left borders otherwise.
- * It's depressingly hard to do in pure CSS…
- */
- private recenterImageElement() {
- const imageDim = this.getDisplayResolution();
- const marginLeft = Math.max((this.containerElement.clientWidth - imageDim.width) / 2, 0);
- const marginTop = Math.max((this.containerElement.clientHeight - imageDim.height) / 2, 0);
- this.imageElement.style.marginLeft = `${marginLeft}px`;
- this.imageElement.style.marginTop = `${marginTop}px`;
- }
-
- private zoom(focusX: number, focusY: number, scaleFactor: number) {
- const imageDim = this.getDisplayResolution();
- const ratioX = focusX / imageDim.width;
- const ratioY = focusY / imageDim.height;
- this.updateImageScale(Math.min(scaleFactor, this.maxScaleFactor));
-
- const newImageDim = this.getDisplayResolution();
- this.containerElement.scrollLeft -= focusX - ratioX * newImageDim.width;
- this.containerElement.scrollTop -= focusY - ratioY * newImageDim.height;
- }
-
- private updateImageScale(newScaleFactor: number) {
- const horizontalFillRatio = this.containerElement.clientWidth / this.pictureProperties.resolution.width;
- const verticalFillRatio = this.containerElement.clientHeight / this.pictureProperties.resolution.height;
- const minScaleFactor = Math.min(horizontalFillRatio, verticalFillRatio, 1.0);
- this.scaleFactor = Math.max(newScaleFactor, minScaleFactor);
- this.resizeImageElement();
- this.recenterImageElement();
- }
-}
diff --git a/viewer/src/services/navigation.ts b/viewer/src/services/navigation.ts
index 5dcea88..b2e807b 100644
--- a/viewer/src/services/navigation.ts
+++ b/viewer/src/services/navigation.ts
@@ -1,7 +1,7 @@
/* ldgallery - A static generator which turns a collection of tagged
-- pictures into a searchable web gallery.
--
--- Copyright (C) 2019-2020 Guillaume FOUET
+-- Copyright (C) 2019-2022 Guillaume FOUET
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as
@@ -17,27 +17,31 @@
-- along with this program. If not, see .
*/
-import { DirectoryItem, Item } from "@/@types/gallery";
-import { ItemType } from "@/@types/ItemType";
+import { DirectoryItem, DownloadableItem, Item } from '@/@types/gallery';
+import { ItemType } from '@/@types/itemType';
+import { faFile, faFileAlt, faFileAudio, faFilePdf, faFileVideo, faFolder, faHome, faImage, IconDefinition } from '@fortawesome/free-solid-svg-icons';
+import { isDirectory } from './itemGuards';
-export default class Navigation {
- static readonly ICON_BY_TYPE: Record = {
- directory: "folder",
- picture: "image",
- plaintext: "file-alt",
- markdown: "file-alt",
- pdf: "file-pdf",
- video: "file-video",
- audio: "file-audio",
- other: "file",
- };
+const ICON_BY_TYPE: Record = {
+ directory: faFolder,
+ picture: faImage,
+ plaintext: faFileAlt,
+ markdown: faFileAlt,
+ pdf: faFilePdf,
+ video: faFileVideo,
+ audio: faFileAudio,
+ other: faFile,
+};
+
+// ---
+export const useNavigation = () => {
// Searches for an item by path from a root item (navigation)
- public static searchCurrentItemPath(root: Item, path: string): Item[] {
+ function searchCurrentItemPath(root: Item, path: string): Item[] {
if (path === root.path) return [root];
- if (root.properties.type === ItemType.DIRECTORY && path.startsWith(root.path)) {
+ if (isDirectory(root) && path.startsWith(root.path)) {
const itemChain = root.properties.items
- .map(item => this.searchCurrentItemPath(item, path))
+ .map(item => searchCurrentItemPath(item, path))
.find(itemChain => itemChain.length > 0);
if (itemChain) return [root, ...itemChain];
}
@@ -45,47 +49,39 @@ export default class Navigation {
}
// Normalize a string to lowercase, no-accents
- public static normalize(value: string) {
+ function normalize(value: string) {
return value
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
- // Checks if the type of an item matches
- public static checkType(item: Item | null, type: ItemType | null): boolean {
- return (item?.properties.type ?? null) === type;
- }
-
- public static getLastDirectory(itemPath: Item[]): DirectoryItem {
+ function getLastDirectory(itemPath: Item[]): DirectoryItem {
for (let idx = itemPath.length - 1; idx >= 0; idx--) {
const item = itemPath[idx];
- if (Navigation.checkType(item, ItemType.DIRECTORY)) return item as DirectoryItem;
+ if (isDirectory(item)) return item;
}
- throw new Error("No directory found");
- }
-
- // Sort a list of items, moving the directories to the beginning of the list
- public static directoriesFirst(items: Item[]) {
- return [
- ...items
- .filter(child => Navigation.checkType(child, ItemType.DIRECTORY))
- .sort((a, b) => a.title.localeCompare(b.title)),
-
- ...items.filter(child => !Navigation.checkType(child, ItemType.DIRECTORY)),
- ];
+ throw new Error('No directory found');
}
// Get the icon for an item
- public static getIcon(item: Item): string {
- if (item.path.length <= 1) return "home";
- return Navigation.ICON_BY_TYPE[item.properties.type];
+ function getIcon(item: Item): IconDefinition {
+ if (item.path.length <= 1) return faHome;
+ return ICON_BY_TYPE[item.properties.type];
}
// Get the file name of an item, without its cache timestamp
- public static getFileName(item: Item): string {
- if (item.properties.type === ItemType.DIRECTORY) return item.title;
- const timeStamped = item.properties.resource.split("/").pop() ?? "";
- return timeStamped.split("?")[0];
+ function getFileName(item: Item): string {
+ if (isDirectory(item)) return item.title;
+ const timeStamped = (item as DownloadableItem).properties.resource.split('/').pop() ?? '';
+ return timeStamped.split('?')[0];
}
-}
+
+ return {
+ searchCurrentItemPath,
+ normalize,
+ getLastDirectory,
+ getIcon,
+ getFileName,
+ };
+};
diff --git a/viewer/src/services/ui/ldFullscreen.ts b/viewer/src/services/ui/ldFullscreen.ts
new file mode 100644
index 0000000..80e755d
--- /dev/null
+++ b/viewer/src/services/ui/ldFullscreen.ts
@@ -0,0 +1,41 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { useUiStore } from '@/store/uiStore';
+import { useEventListener } from '@vueuse/core';
+import { watch } from 'vue';
+
+export const useLdFullscreen = () => {
+ const uiStore = useUiStore();
+
+ useEventListener(document, 'fullscreenchange', onFullscreenChange);
+
+ function onFullscreenChange() {
+ uiStore.toggleFullscreen(isFullscreenActive());
+ }
+
+ function isFullscreenActive(): boolean {
+ return Boolean(document.fullscreenElement);
+ }
+
+ watch(() => uiStore.fullscreen, (fullscreen) => {
+ if (fullscreen && !isFullscreenActive()) document.body.requestFullscreen();
+ else if (isFullscreenActive()) document.exitFullscreen();
+ });
+};
diff --git a/viewer/src/services/ui/ldItemResourceUrl.ts b/viewer/src/services/ui/ldItemResourceUrl.ts
new file mode 100644
index 0000000..7db7ab9
--- /dev/null
+++ b/viewer/src/services/ui/ldItemResourceUrl.ts
@@ -0,0 +1,15 @@
+import { Item } from '@/@types/gallery';
+import { useGalleryStore } from '@/store/galleryStore';
+import { computed } from 'vue';
+import { isDownloadableItem } from '../itemGuards';
+
+export const useItemResource = (item: Item) => {
+ const galleryStore = useGalleryStore();
+ const itemResourceUrl = computed(() => isDownloadableItem(item) ? galleryStore.resourceRoot + item.properties.resource : '');
+ const thumbnailResourceUrl = computed(() => item.thumbnail ? galleryStore.resourceRoot + item.thumbnail.resource : '');
+
+ return {
+ itemResourceUrl,
+ thumbnailResourceUrl,
+ };
+};
diff --git a/viewer/src/services/ui/ldKeepFocus.ts b/viewer/src/services/ui/ldKeepFocus.ts
new file mode 100644
index 0000000..ce9cbc8
--- /dev/null
+++ b/viewer/src/services/ui/ldKeepFocus.ts
@@ -0,0 +1,34 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { MaybeElementRef, useFocus } from '@vueuse/core';
+import { watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+export const useLdKeepFocus = (element: MaybeElementRef) => {
+ const contentFocus = useFocus(element);
+ const route = useRoute();
+
+ watch(() => route.path, moveFocus);
+
+ function moveFocus() {
+ contentFocus.focused.value = true;
+ }
+ return { moveFocus };
+};
diff --git a/viewer/src/services/ui/ldKeyboard.ts b/viewer/src/services/ui/ldKeyboard.ts
new file mode 100644
index 0000000..8576435
--- /dev/null
+++ b/viewer/src/services/ui/ldKeyboard.ts
@@ -0,0 +1,28 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { useUiStore } from '@/store/uiStore';
+import { onKeyStroke } from '@vueuse/core';
+
+export const useLdKeyboard = () => {
+ const uiStore = useUiStore();
+
+ // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
+ onKeyStroke('Escape', () => uiStore.toggleFullscreen(false));
+};
diff --git a/viewer/src/services/ui/ldSaveScroll.ts b/viewer/src/services/ui/ldSaveScroll.ts
new file mode 100644
index 0000000..34e277b
--- /dev/null
+++ b/viewer/src/services/ui/ldSaveScroll.ts
@@ -0,0 +1,37 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { MaybeElementRef, unrefElement } from '@vueuse/core';
+import { nextTick, watch } from 'vue';
+import { useRoute } from 'vue-router';
+
+type ScrollPosition = Record;
+
+export const useLdSaveScroll = (element: MaybeElementRef) => {
+ const scrollPositions: ScrollPosition = {};
+ const route = useRoute();
+
+ watch(() => decodeURIComponent(route.path), (newRoute, oldRoute) => {
+ const el = unrefElement(element);
+ if (!el) return;
+
+ scrollPositions[oldRoute] = el.scrollTop / el.scrollHeight;
+ nextTick(() => (el.scrollTop = scrollPositions[newRoute] * el.scrollHeight));
+ });
+};
diff --git a/viewer/src/services/ui/ldTitle.ts b/viewer/src/services/ui/ldTitle.ts
new file mode 100644
index 0000000..df80140
--- /dev/null
+++ b/viewer/src/services/ui/ldTitle.ts
@@ -0,0 +1,34 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2019-2022 Guillaume FOUET
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { useGalleryStore } from '@/store/galleryStore';
+import { useTitle } from '@vueuse/core';
+import { computed } from 'vue';
+
+export const useLdTitle = () => {
+ const galleryStore = useGalleryStore();
+
+ const title = computed(() => {
+ const { currentItem, galleryTitle } = galleryStore;
+ return currentItem?.title
+ ? `${currentItem.title} • ${galleryTitle}`
+ : galleryTitle;
+ });
+ useTitle(title);
+};
diff --git a/viewer/src/services/ui/ldZoom.ts b/viewer/src/services/ui/ldZoom.ts
new file mode 100644
index 0000000..9f77dea
--- /dev/null
+++ b/viewer/src/services/ui/ldZoom.ts
@@ -0,0 +1,128 @@
+/* ldgallery - A static generator which turns a collection of tagged
+-- pictures into a searchable web gallery.
+--
+-- Copyright (C) 2020 Pacien TRAN-GIRARD
+--
+-- This program is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as
+-- published by the Free Software Foundation, either version 3 of the
+-- License, or (at your option) any later version.
+--
+-- This program 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 Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+*/
+
+import { PictureProperties, Resolution } from '@/@types/gallery';
+import { importHammer } from '@/plugins/asyncLib';
+import { CSSProperties, Ref } from 'vue';
+
+/**
+ * Mousewheel and pinch zoom handler.
+ */
+export const useLdZoom = (
+ imageStyle: Ref,
+ containerElement: HTMLDivElement,
+ imageElement: HTMLImageElement,
+ pictureProperties: PictureProperties,
+ maxScaleFactor = 10,
+ scrollZoomSpeed: number = 1 / 7,
+) => {
+ let scaleFactor = 0.0;
+
+ /**
+ * Register event listeners.
+ */
+ updateImageScale(scaleFactor);
+
+ new ResizeObserver(() => {
+ updateImageScale(scaleFactor);
+ }).observe(containerElement);
+
+ containerElement.addEventListener('wheel', wheelEvent => {
+ wheelEvent.preventDefault();
+ const newScaleFactor = scaleFactor - Math.sign(wheelEvent.deltaY) * (scrollZoomSpeed * scaleFactor);
+ zoom(wheelEvent.offsetX, wheelEvent.offsetY, newScaleFactor);
+ });
+
+ importHammer().then(() => {
+ const pinchListener = new Hammer(containerElement);
+ pinchListener.get('pinch').set({ enable: true });
+ installPinchHandler(pinchListener);
+ });
+
+ return { imageStyle };
+
+ // ---
+
+ function installPinchHandler(pinchListener: HammerManager) {
+ let startScaleFactor = 0.0;
+
+ pinchListener.on('pinchstart', () => {
+ startScaleFactor = scaleFactor;
+ });
+
+ pinchListener.on('pinchmove', (pinchEvent: HammerInput) => {
+ const focusX = pinchEvent.center.x + containerElement.scrollLeft;
+ const focusY = pinchEvent.center.y + containerElement.scrollTop;
+ const scaleFactor = pinchEvent.scale * startScaleFactor;
+ zoom(focusX, focusY, scaleFactor);
+ });
+ }
+
+ /**
+ * Returns the picture resolution as it should be displayed.
+ */
+ function getDisplayResolution(): Resolution {
+ return {
+ width: pictureProperties.resolution.width * scaleFactor,
+ height: pictureProperties.resolution.height * scaleFactor,
+ };
+ }
+
+ /**
+ * Applies scaling to the DOM image element.
+ * To call after internal intermediate computations because DOM properties aren't stable.
+ */
+ function resizeImageElement() {
+ const imageDim = getDisplayResolution();
+ imageElement.width = imageDim.width;
+ imageElement.height = imageDim.height;
+ }
+
+ /**
+ * Centers the image element inside its container if it fits, or stick to the top and left borders otherwise.
+ * It's depressingly hard to do in pure CSS…
+ */
+ function recenterImageElement() {
+ const imageDim = getDisplayResolution();
+ const marginLeft = Math.max((containerElement.clientWidth - imageDim.width) / 2, 0);
+ const marginTop = Math.max((containerElement.clientHeight - imageDim.height) / 2, 0);
+ imageStyle.value.marginLeft = `${marginLeft}px`;
+ imageStyle.value.marginTop = `${marginTop}px`;
+ }
+
+ function zoom(focusX: number, focusY: number, scaleFactor: number) {
+ const imageDim = getDisplayResolution();
+ const ratioX = focusX / imageDim.width;
+ const ratioY = focusY / imageDim.height;
+ updateImageScale(Math.min(scaleFactor, maxScaleFactor));
+
+ const newImageDim = getDisplayResolution();
+ containerElement.scrollLeft -= focusX - ratioX * newImageDim.width;
+ containerElement.scrollTop -= focusY - ratioY * newImageDim.height;
+ }
+
+ function updateImageScale(newScaleFactor: number) {
+ const horizontalFillRatio = containerElement.clientWidth / pictureProperties.resolution.width;
+ const verticalFillRatio = containerElement.clientHeight / pictureProperties.resolution.height;
+ const minScaleFactor = Math.min(horizontalFillRatio, verticalFillRatio, 1.0);
+ scaleFactor = Math.max(newScaleFactor, minScaleFactor);
+ resizeImageElement();
+ recenterImageElement();
+ }
+};
--
cgit v1.2.3