From 557e87ca3c6e68b76a5246f71ef2d3e0af5ad4c7 Mon Sep 17 00:00:00 2001 From: Varun Patil Date: Mon, 23 Oct 2023 22:12:48 -0700 Subject: [PATCH] refactor(fragment): support multiple frags Signed-off-by: Varun Patil --- src/bootstrap.ts | 1 + src/components/SelectionManager.vue | 2 +- src/components/Timeline.vue | 17 +- src/components/top-matter/MapSplitMatter.vue | 2 +- src/components/viewer/Viewer.vue | 44 ++--- src/globals.d.ts | 6 +- src/services/utils/fragment.ts | 166 +++++++++++++++++++ src/services/utils/helpers.ts | 18 -- src/services/utils/index.ts | 1 + 9 files changed, 194 insertions(+), 63 deletions(-) create mode 100644 src/services/utils/fragment.ts diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 1ef482ce..b435f2ff 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -28,6 +28,7 @@ globalThis._m = { get route() { return router.currentRoute; }, + router: router, routes: routes, modals: {} as any, diff --git a/src/components/SelectionManager.vue b/src/components/SelectionManager.vue index 09e086e0..28a0ea7e 100644 --- a/src/components/SelectionManager.vue +++ b/src/components/SelectionManager.vue @@ -867,7 +867,7 @@ export default defineComponent({ /** Open viewer with given photo */ openViewer(photo: IPhoto) { nativex.playTouchSound(); - this.$router.push(utils.getViewerRoute(photo)); + _m.viewer.open(photo); }, }, }); diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 0828122c..3bdcfe35 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -269,14 +269,9 @@ export default defineComponent({ await this.$nextTick(); // Check if hash has changed - if (from?.hash !== to.hash && to.hash?.startsWith('#v') && !_m.viewer.isOpen) { + if (from?.hash !== to.hash && !_m.viewer.isOpen && utils.fragment.viewer.open) { // Open viewer - const parts = to.hash.split('/'); - if (parts.length !== 3) return; - - // Get params - const dayid = parseInt(parts[1]); - const key = parts[2]; + const { dayid, key } = utils.fragment.viewer; if (isNaN(dayid) || !key) return; // Get day @@ -299,9 +294,9 @@ export default defineComponent({ } } - _m.viewer.open(photo, this.list); - } else if (!to.hash?.startsWith('#v') && _m.viewer.isOpen) { - // Close viewer + _m.viewer.openDynamic(photo, this.list); + } else if (!utils.fragment.viewer.open && _m.viewer.isOpen) { + // No viewer fragment but viewer is open _m.viewer.close(); } }, @@ -693,7 +688,7 @@ export default defineComponent({ data = await dav.getOnThisDayData(); } else if (dav.isSingleItem()) { data = await dav.getSingleItemData(); - this.$router.replace(utils.getViewerRoute(data[0]!.detail![0])); + _m.viewer.open(data[0]!.detail![0]); } else { // Try the cache if (!noCache) { diff --git a/src/components/top-matter/MapSplitMatter.vue b/src/components/top-matter/MapSplitMatter.vue index 319242dd..c531a744 100644 --- a/src/components/top-matter/MapSplitMatter.vue +++ b/src/components/top-matter/MapSplitMatter.vue @@ -298,7 +298,7 @@ export default defineComponent({ // At high zoom levels, open the photo if (this.zoom >= 12 && cluster.preview) { cluster.preview.key = cluster.preview.fileid.toString(); - this.$router.push(utils.getViewerRoute(cluster.preview)); + _m.viewer.open(cluster.preview); return; } diff --git a/src/components/viewer/Viewer.vue b/src/components/viewer/Viewer.vue index 392b55c5..37321a0d 100644 --- a/src/components/viewer/Viewer.vue +++ b/src/components/viewer/Viewer.vue @@ -287,7 +287,8 @@ export default defineComponent({ // The viewer is a singleton const self = this; _m.viewer = { - open: this.open.bind(this) as typeof this.open, + open: this.setFragment.bind(this) as typeof this.setFragment, + openDynamic: this.openDynamic.bind(this) as typeof this.openDynamic, openStatic: this.openStatic.bind(this) as typeof this.openStatic, close: this.close.bind(this) as typeof this.close, get isOpen() { @@ -553,7 +554,7 @@ export default defineComponent({ this.fullyOpened = false; this.setUiVisible(false); this.hideSidebar(); - this.setRouteHash(undefined); + this.setFragment(null); this.updateTitle(undefined); nativex.setTheme(); // reset document.body.classList.remove(BODY_VIEWER_VIDEO); @@ -581,7 +582,7 @@ export default defineComponent({ this.photoswipe.on('slideActivate', (e) => { this.currIndex = this.photoswipe!.currIndex; const photo = e.slide?.data?.photo; - this.setRouteHash(photo); + this.setFragment(photo); this.updateTitle(photo); }); @@ -636,7 +637,7 @@ export default defineComponent({ }, /** Open using start photo and rows list */ - async open(anchorPhoto: IPhoto, rows: IRow[]) { + async openDynamic(anchorPhoto: IPhoto, rows: IRow[]) { const detail = anchorPhoto.d?.detail; if (!detail) { console.error('Attempted to open viewer with no detail list!'); @@ -865,33 +866,16 @@ export default defineComponent({ }, /** Set the route hash to the given photo */ - setRouteHash(photo: IPhoto | undefined) { - if (!photo) { - if (!this.isOpen && this.$route.hash?.startsWith('#v')) { - this.$router.back(); - - // Ensure this does not have the hash, otherwise replace it - if (this.$route.hash?.startsWith('#v')) { - this.$router.replace({ - hash: '', - query: this.$route.query, - }); - } - } - return; + setFragment(photo: IPhoto | null) { + if (photo) { + const frag = utils.fragment.viewer; + frag.dayid = photo.dayid; + frag.key = photo.key!; + return utils.fragment.push(frag); } - const hash = photo ? utils.getViewerHash(photo) : ''; - const route = { - path: this.$route.path, - query: this.$route.query, - hash, - }; - if (hash !== this.$route.hash) { - if (this.$route.hash) { - this.$router.replace(route); - } else { - this.$router.push(route); - } + + if (!this.isOpen) { + return utils.fragment.pop('v'); } }, diff --git a/src/globals.d.ts b/src/globals.d.ts index cb3055b9..41180c33 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,4 +1,4 @@ -import type { Route } from 'vue-router'; +import Router, { Route } from 'vue-router'; import type { ComponentPublicInstance } from 'vue'; import type { translate, translatePlural } from '@nextcloud/l10n'; @@ -32,6 +32,7 @@ declare global { var _m: { mode: 'admin' | 'user'; route: Route; + router: Router; routes: typeof routes; modals: { @@ -52,7 +53,8 @@ declare global { }; viewer: { - open: (anchorPhoto: IPhoto, rows: IRow[]) => Promise; + open: (photo: IPhoto) => void; + openDynamic: (anchorPhoto: IPhoto, rows: IRow[]) => Promise; openStatic(photo: IPhoto, list: IPhoto[], thumbSize?: 256 | 512): Promise; close: () => void; isOpen: boolean; diff --git a/src/services/utils/fragment.ts b/src/services/utils/fragment.ts new file mode 100644 index 00000000..200dd769 --- /dev/null +++ b/src/services/utils/fragment.ts @@ -0,0 +1,166 @@ +import type { IPhoto } from '../../types'; + +/** Viewer Fragment */ +type FragmentTypeViewer = 'v'; + +/** All types of fragmemts */ +type FragmentType = FragmentTypeViewer; + +/** Data structure to encode to fragment */ +type FragmentObj = { + type: FragmentType; + args: string[]; + index: number; +}; + +/** + * Decode fragments from string. + * @param hash Hash string + */ +function decodeFragment(hash: string): FragmentObj[] { + return hash + .substring(1) // remove # at start + .split('&') // get all parts + .filter((frag) => frag) // remove empty parts + .map((frag, i, arr) => { + const values = frag?.split('/'); + return { + type: (values?.[0] ?? 'u') as FragmentType, + args: values?.slice(1) ?? [], + index: arr.length - i - 1, + }; + }); +} + +/** + * Encode fragments to string. + * @param fragments Fragments to encode + */ +function encodeFragment(fragments: FragmentObj[]): string { + if (!fragments.length) return ''; + return '#' + fragments.map((frag) => [frag.type, ...frag.args].join('/')).join('&'); +} + +/** + * Cache for route fragments + */ +const cache = { + hash: String(), + list: [] as FragmentObj[], +}; + +export const fragment = { + /** + * Get list of all fragments in route. + * @returns List of fragments + */ + get list(): FragmentObj[] { + if (cache.hash !== _m.route.hash) { + cache.hash = _m.route.hash; + cache.list = decodeFragment(cache.hash ?? String()); + } + + return cache.list; + }, + + /** + * Check if route has this fragment type. + * @param type Fragment identifier + */ + get(type: FragmentType) { + return this.list.find((frag) => frag.type === type); + }, + + /** + * Add fragment to route. + * @param frag Fragment to add to route + */ + push(frag: FragmentObj) { + const list = this.list; + + // Get the top fragment + const top = list[list.length - 1]; + + // Check if we are already on this fragment + if (top?.type === frag.type) { + // Replace the arguments + top.args = frag.args; + const hash = encodeFragment(list); + + // Avoid redundant route changes + if (hash === _m.route.hash) return; + + // Replace the route with the new fragment + _m.router.replace({ + path: _m.route.path, + query: _m.route.query, + hash: hash, + }); + + return; + } + + // If the fragment is already in the list, + // we can't touch it. This should never happen. + if (list.find((f) => f.type === frag.type)) { + console.error('[BUG] Fragment already in route', frag.type); + } + + // Add fragment to route + list.push(frag); + _m.router.push({ + path: _m.route.path, + query: _m.route.query, + hash: encodeFragment(list), + }); + }, + + /** + * Remove the top fragment from route. + * @param type Fragment identifier + */ + pop(type: FragmentType) { + // Get the index of this fragment from the end + const frag = this.get(type); + if (!frag) return; + + // Go back in history + _m.router.go(-frag.index - 1); + + // Check if the fragment still exists + // In that case, replace the route to remove the fragment + const sfrag = this.get(type); + if (sfrag) { + _m.router.replace({ + path: _m.route.path, + query: _m.route.query, + hash: encodeFragment(this.list.slice(0, -sfrag.index - 1)), + }); + } + }, + + get viewer() { + const frag = this.get('v'); + const typed = { + open: !!frag, + type: frag?.type ?? 'v', + args: (frag?.args ?? ['0', '']) as [string, string], + index: frag?.index ?? -1, + + get dayid() { + return parseInt(this.args[0]); + }, + set dayid(dayid: number) { + this.args[0] = String(dayid); + }, + + get key() { + return this.args[1]; + }, + set key(key: string) { + this.args[1] = key; + }, + }; + return typed; + }, +}; diff --git a/src/services/utils/helpers.ts b/src/services/utils/helpers.ts index 5dd630c0..49fd8164 100644 --- a/src/services/utils/helpers.ts +++ b/src/services/utils/helpers.ts @@ -190,24 +190,6 @@ export function setupLivePhotoHooks(video: HTMLVideoElement) { }; } -/** - * Get route hash for viewer for photo - */ -export function getViewerHash(photo: IPhoto) { - return `#v/${photo.dayid}/${photo.key}`; -} - -/** - * Get route for viewer for photo - */ -export function getViewerRoute(photo: IPhoto) { - return { - path: _m.route.path, - query: _m.route.query, - hash: getViewerHash(photo), - }; -} - /** * Choose a folder using the NC file picker * diff --git a/src/services/utils/index.ts b/src/services/utils/index.ts index 8daff01c..22ea6cd2 100644 --- a/src/services/utils/index.ts +++ b/src/services/utils/index.ts @@ -5,3 +5,4 @@ export * from './date'; export * from './helpers'; export * from './dialog'; export * from './event-bus'; +export * from './fragment';