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