/
githubmirror
/
photoprism
Обзор
Документация
Войти
/
githubmirror
/
photoprism
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
frontend/src/common/view.js
1 268 строк
35 KB
Michael Mayer
Lightbox: Restore iOS swipe-back suppression in PDF viewer #5488
24 июл 2026, 14:12
24 июл 2026, 14:12
696321d
Код
Авторство
О чём код?
import { toRaw } from "vue"; import $notify from "common/notify"; import { getAppSessionStorage, getAppStorage } from "common/storage"; const TouchStartEvent = "touchstart"; const TouchMoveEvent = "touchmove"; // Selector for the document's viewport meta tag. const ViewportMetaSelector = 'meta[name="viewport"]'; // Viewport content that disables the browser's native pinch-zoom of the whole page. const ViewportNoZoom = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"; // Token in a viewport content string indicating native zoom is already disabled. const ViewportNoZoomToken = "user-scalable=no"; // True if debug and/or trace logs should be recorded. const debug = window.__CONFIG__?.debug; const trace = window.__CONFIG__?.trace; // Enumerates the possible navigation directions observed between Vue Router history states. const NavigationDirection = Object.freeze({ None: "none", Back: "back", Forward: "forward", Replace: "replace", }); // Reads the current history state if the environment exposes the history API. const getHistoryState = () => { if (typeof window === "undefined" || typeof window.history === "undefined") { return undefined; } return window.history.state; }; // Extracts the numeric `position` field Vue Router stores inside history.state. const parseHistoryPosition = (state) => { if (!state || typeof state !== "object") { return undefined; } const numeric = Number(state.position); if (!Number.isFinite(numeric)) { return undefined; } return numeric; }; // Returns the <html> element. export function getHtmlElement() { return document.documentElement; } // Initializes the <html> element by removing the "class" attribute. export function initHtmlElement() { const htmlElement = document.documentElement; if (htmlElement && htmlElement.hasAttribute("class")) { if (debug) { console.log(`html: removed class="${htmlElement.getAttribute("class")}"`); } // Remove the class="loading" attribute from <html> when the application has loaded. htmlElement.removeAttribute("class"); htmlElement.setAttribute("style", ""); // If requested, hide the scrollbar permanently by adding class="hide-scrollbar" to <html>. if (document.body.classList.contains("hide-scrollbar")) { htmlElement.setAttribute("class", "hide-scrollbar"); if (debug) { console.log('html: added class="hide-scrollbar" to permanently hide the scrollbar'); } } } } // Set a :root style variable, or removes it if the value is empty. export function setHtmlStyle(key, value) { if (!key) { return false; } const htmlElement = getHtmlElement(); if (!htmlElement) { return false; } else if (value) { htmlElement.style.setProperty(key, value); } else { htmlElement.style.removeProperty(key); } return true; } // Returns the <body> element. export function getBodyElement() { return document.body; } // Returns the width of the vertical window scrollbar. export function getScrollbarWidth() { const body = getBodyElement(); if (!body || !window.innerWidth) { return 0; } return window.innerWidth - body.offsetWidth; } // Component refs supported for automatic focus element detection. const focusRefs = ["form", "content", "container", "dialog", "page"]; // Returns true if the given value looks like a persisted scroll position. const isPos = (v) => v && typeof v === "object" && Number.isFinite(v.left) && Number.isFinite(v.top); // Minimal localStorage wrapper that tolerates quota / access errors. const storage = { get(key) { try { return getAppStorage().getItem(key); } catch { return null; } }, set(key, val) { try { getAppStorage().setItem(key, val); } catch { /* ignore */ } }, remove(key) { try { getAppStorage().removeItem(key); localStorage.removeItem(key); } catch { /* ignore */ } }, }; // Minimal sessionStorage wrapper for ephemeral navigation state. const sessionStore = { get(key) { try { return getAppSessionStorage().getItem(key); } catch { return null; } }, set(key, val) { try { getAppSessionStorage().setItem(key, val); } catch { /* ignore */ } }, remove(key) { try { getAppSessionStorage().removeItem(key); sessionStorage.removeItem(key); } catch { /* ignore */ } }, }; const restoreNamespace = "view.restore."; const restoreMaxAgeMs = 30 * 60 * 1000; // 30 minutes const encodeRestoreKey = (key) => { if (!key || typeof key !== "string") { return ""; } return restoreNamespace + encodeURIComponent(key); }; // resolveFocusTarget returns the most appropriate element inside root for initial focus. function resolveFocusTarget(root) { if (!root) { return null; } let el = getHTMLElement(root); if (!(el instanceof HTMLElement)) { return null; } if (el.hasAttribute("autofocus")) { return el; } let candidate = null; if (el.getAttribute("tabindex") === "-1") { candidate = el; } try { const autofocus = el.querySelector("[autofocus]"); if (autofocus instanceof HTMLElement) { return autofocus; } const sentinel = el.querySelector('[tabindex="-1"]'); if (sentinel instanceof HTMLElement) { return sentinel; } } catch { // Ignore. } return candidate; } // getHTMLElement normalizes Vue component refs or DOM nodes to a concrete HTMLElement. function getHTMLElement(ref) { if (!ref) { return null; } if (ref instanceof HTMLElement) { return ref; } else if (ref.contentEl && ref.contentEl instanceof HTMLElement) { return ref.contentEl; } else if (ref.$el && ref.$el instanceof HTMLElement) { return ref.$el; } return null; } // resolveFocusScope determines the container and fallback focus element for trapping focus within a component. function resolveFocusScope(component) { if (!component || !component.$refs) { return null; } const root = getHTMLElement(component.$refs?.dialog); if (!root) { return null; } const fallback = resolveFocusTarget(root); if (fallback && root.contains(fallback)) { return { root, fallback, }; } return { root, fallback: root, }; } // Returns the most likely focus element for the given component, or null if none exists. export function findFocusElement(c) { if (!c) { return null; } const candidates = []; if (c.$refs && c.$refs instanceof Object) { focusRefs.forEach((r) => { if (c.$refs[r]) { const el = getHTMLElement(c.$refs[r]); if (el) { candidates.push(el); } } }); } const el = getHTMLElement(c); if (el) { candidates.push(el); } for (let i = 0; i < candidates.length; i++) { const target = resolveFocusTarget(candidates[i]); if (target) { return target; } } if (c.$refs?.dialog) { return getHTMLElement(c.$refs.dialog); } return null; } // Gives focus to the specified HTML element, or the first element that matches the specified selector string. export function setFocus(el, selector, scroll) { if (!el) { return false; } let options = { preventScroll: !scroll }; if (typeof el === "string") { el = document.querySelector(el); } else if (el instanceof Object) { if (!selector && typeof el.focus === "function") { try { el.focus(options); return true; } catch (err) { console.log(`failed to call el.focus(): ${err}`, el); } } if (el.$el && el.$el instanceof HTMLElement) { el = el.$el; } } if (el && el instanceof HTMLElement) { if (selector && typeof selector === "string") { el = el.querySelector(selector); if (!el || !(el instanceof HTMLElement)) { return false; } } if (trace) { console.log("giving focus to this element:", el); } try { el.focus(options); return true; } catch (err) { console.log(`failed to give focus to element: ${err}`, el); } } else if (trace) { console.log("invalid focus element:", el); } return false; } // Edge band (px) at the viewport sides/top where iOS swipe-back and browser // pull-to-refresh gestures originate; touches inside it are treated as nav gestures. const NavGestureEdgeBand = 30; // Tap-actionable elements exempted from edge-band preventDefault so buttons, // inputs, and links inside an edge band stay reliable on touch devices. const InteractiveTargetSelector = 'button, input, textarea, select, a[href], [role="button"]'; // isInteractiveTarget reports whether the touch target is (or sits inside) a tappable widget. function isInteractiveTarget(target) { if (!target || typeof target.closest !== "function") { return false; } return target.closest(InteractiveTargetSelector) !== null; } // preventNavigationTouchEvent suppresses iOS swipe-back, browser pull-to-refresh, and // accidental horizontal navigation while the lightbox is active. Scoped to edge bands // only — inner-area touches and taps on interactive widgets pass through. Runs in the // capture phase (see _preventNavOptions) so overlays that stopPropagation to own their // gestures, like the PDF viewer, can't defeat it. export function preventNavigationTouchEvent(ev) { if (!(ev instanceof TouchEvent) || !ev.cancelable) { return; } if (ev.type !== TouchStartEvent && ev.type !== TouchMoveEvent) { return; } const touch = ev.touches[0] || (ev.changedTouches && ev.changedTouches[0]); if (!touch) { return; } const atLeftEdge = touch.clientX <= NavGestureEdgeBand; const atRightEdge = touch.clientX >= window.innerWidth - NavGestureEdgeBand; const atTopEdge = touch.clientY <= NavGestureEdgeBand; if (!atLeftEdge && !atRightEdge && !atTopEdge) { return; } if (isInteractiveTarget(ev.target)) { return; } ev.preventDefault(); } // Returns a random string that can be used as an identifier. export function generateRandomId() { return Date.now().toString(36) + Math.random().toString(36).substring(2, 18); } // View keeps track of the visible components and dialogs, // and updates the window and <html> body as needed. export class View { // Initializes the instance properties with the default values. constructor() { this.uid = 0; this.current = null; this.scopes = []; this.hideScrollbar = false; this.preventNavigation = false; this.savedViewportContent = ""; this.focusScopes = new Map(); // Tracks the most recent history position and derived navigation direction so components can // determine whether a transition was triggered by browser back/forward buttons. this.navigation = { currentPosition: parseHistoryPosition(getHistoryState()), pendingPosition: undefined, direction: NavigationDirection.None, consumed: false, }; // Bind keydown handler once so it can be removed if needed (avoids leaking bound fns). this._onKeyDownListener = this.onKeyDown.bind(this); addEventListener("keydown", this._onKeyDownListener); // Register a single document-level focus handler, so dialogs can keep keyboard focus inside their scope. this._onFocusOutListener = this.onDocumentFocusOut.bind(this); document.addEventListener("focusout", this._onFocusOutListener); // Guard listener options; a stable reference lets add/removeEventListener match. // capture:true so a descendant that stops touch propagation (e.g. the PDF viewer's // @touchstart.stop) can't defeat the window-level guard. this._preventNavOptions = { passive: false, capture: true }; if (trace) { // Store trace handlers so they can be removed later if needed. this._traceFocusIn = (ev) => { console.log("%cdocument.focusin", "color: #B2EBF2;", ev.target); }; document.addEventListener("focusin", this._traceFocusIn); } } // destroy unregisters the global listeners so the view helper can be garbage-collected safely. destroy() { removeEventListener("keydown", this._onKeyDownListener); document.removeEventListener("focusout", this._onFocusOutListener); if (this._traceFocusIn) { document.removeEventListener("focusin", this._traceFocusIn); } } // onKeyDown forwards global shortcuts (Escape, Ctrl/⌘ combos) to the active component when supported. onKeyDown(ev) { if (!this.current || !ev || !(ev instanceof KeyboardEvent) || !ev.code) { return; } else if (!ev.ctrlKey && !ev.metaKey && ev.code !== "Escape") { return; } else if (typeof this.current?.onShortCut !== "function") { return; } if (this.current.onShortCut(ev)) { ev.preventDefault(); } } // Changes the view context to the specified component, // and updates the window and <html> body as needed. enter(c, focusElement, focusSelector) { if (!c) { return false; } if (this.isRoot()) { initHtmlElement(); } if (c !== this.getCurrent()) { this.scopes.push(c); } this.current = c; this.apply(c, focusElement, focusSelector); return this.scopes.length; } // Returns to the parent view context of the specified component, // and updates the window and <html> body as needed. leave(c) { if (!c || this.scopes.length === 0) { return false; } const index = this.scopes.findLastIndex((s) => s === c); if (index > 0) { this.scopes.splice(index, 1); } else if (index < 0) { return; } if (this.scopes.length) { this.current = this.scopes[this.scopes.length - 1]; this.apply(this.current); } // Remove any stale focus scope once the component leaves the stack. this.focusScopes.delete(c); return this.scopes.length; } // Updates the window and the <html> body elements based on the specified component. apply(c, focusElement, focusSelector) { if (!c || typeof c !== "object" || !Number.isInteger(c?.$?.uid) || !c.$el) { console.log(`view: invalid component (#${this.uid.toString()})`, c); return false; } // Get the component's name and numeric ID. const name = c?.$options?.name ? c.$options.name : ""; const uid = c.$.uid; if (!name) { console.log(`view: component needs a name (#${uid})`, c); return false; } // Write debug logs to a collapsed group with a recognizable purple header. if (debug) { const scope = this.scopes.map((s) => `${s?.$options?.name} #${s?.$?.uid.toString()}`).join(" › "); console.groupCollapsed(`%c${scope}`, "background: #502A85; color: white; padding: 3px 5px; border-radius: 8px; font-weight: bold;"); console.log("data:", toRaw(c?.$data)); } // Automatically focus the active component based on autofocus markers or tabindex sentinels: // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex if (focusElement) { setFocus(focusElement, focusSelector, false); } else { setFocus(findFocusElement(c), false, false); } // Capture the most recent focusable root so we can trap focus if this component opens a dialog. this.recordFocusScope(c); // Return, as it should not be necessary to apply the same state twice. if (this.uid === uid) { if (debug) { console.groupEnd(); } return; } let hideScrollbar = this.len() > 2 ? this.hideScrollbar : false; let disableScrolling = false; let disableNavigationGestures = false; let disableViewportZoom = false; let preventNavigation = uid > 0 && !name.startsWith("PPage"); switch (name) { case "PPagePlaces": hideScrollbar = true; break; case "PServiceUpload": preventNavigation = false; break; case "PPageLogin": hideScrollbar = true; preventNavigation = true; break; case "PPhotoEditDialog": hideScrollbar = window.innerWidth < 960; disableScrolling = true; preventNavigation = true; break; case "PUploadDialog": hideScrollbar = window.innerWidth < 1280; disableScrolling = true; preventNavigation = true; break; case "PLightbox": hideScrollbar = true; disableScrolling = true; disableNavigationGestures = true; disableViewportZoom = true; preventNavigation = true; break; } this.hideScrollbar = hideScrollbar; this.preventNavigation = preventNavigation; const htmlEl = getHtmlElement(); if (!htmlEl) { if (debug) { console.log(`html: failed to get element (#${this.uid.toString()})`, c); console.groupEnd(); } return false; } const bodyEl = getBodyElement(); if (!bodyEl) { if (debug) { console.log(`body: failed to get element (#${this.uid.toString()})`, c); console.groupEnd(); } return false; } if (hideScrollbar) { if (!bodyEl.classList.contains("hide-scrollbar")) { bodyEl.classList.add("hide-scrollbar"); setHtmlStyle("scrollbar-width", "none"); setHtmlStyle("overflow-y", "hidden"); if (debug) { console.log(`html: added style="scrollbar-width: none; overflow-y: hidden;"`); } } } else if (bodyEl.classList.contains("hide-scrollbar")) { bodyEl.classList.remove("hide-scrollbar"); setHtmlStyle("scrollbar-width"); setHtmlStyle("overflow-y"); if (debug) { console.log(`html: removed style="scrollbar-width: none; overflow-y: hidden;"`); } } if (disableScrolling) { if (!bodyEl.classList.contains("disable-scrolling")) { bodyEl.classList.add("disable-scrolling"); if (debug) { console.log(`body: added class="disable-scrolling"`); } } } else if (bodyEl.classList.contains("disable-scrolling")) { bodyEl.classList.remove("disable-scrolling"); if (debug) { console.log(`body: removed class="disable-scrolling"`); } } if (disableNavigationGestures) { if (!bodyEl.classList.contains("disable-navigation-gestures")) { bodyEl.classList.add("disable-navigation-gestures"); window.addEventListener(TouchStartEvent, preventNavigationTouchEvent, this._preventNavOptions); window.addEventListener(TouchMoveEvent, preventNavigationTouchEvent, this._preventNavOptions); if (debug) { console.log(`view: disabled touch navigation gestures`); } } } else if (bodyEl.classList.contains("disable-navigation-gestures")) { bodyEl.classList.remove("disable-navigation-gestures"); window.removeEventListener(TouchStartEvent, preventNavigationTouchEvent, this._preventNavOptions); window.removeEventListener(TouchMoveEvent, preventNavigationTouchEvent, this._preventNavOptions); if (debug) { console.log(`view: re-enabled touch navigation gestures`); } } // Lock native pinch-zoom while a flagged overlay is active, restore it otherwise. // Both helpers are idempotent, so re-applying the same state is a no-op. if (disableViewportZoom) { this.disableNativeZoom(); } else { this.restoreNativeZoom(); } if (debug) { console.groupEnd(); } return true; } // recordFocusScope caches the DOM boundary used to keep focus inside the active component. recordFocusScope(component) { if (!component) { return; } const scope = resolveFocusScope(component); // Clear existing traps when we cannot resolve a focus container (e.g., simple pages). if (!scope) { this.focusScopes.delete(component); return; } const { root } = scope; // Ensure the focus container can receive focus, which some Vuetify overlays require explicitly. if (root && !root.hasAttribute("tabindex")) { root.setAttribute("tabindex", "-1"); } // Remember the trapping metadata so onDocumentFocusOut can redirect focus if needed. this.focusScopes.set(component, scope); } // onDocumentFocusOut re-focuses the current dialog when keyboard focus attempts to leave its scope. onDocumentFocusOut(ev) { if (trace) { console.log("%cdocument.focusout", "color: #B2EBF2;", ev?.target); } if (!this.current || !ev || !(ev instanceof FocusEvent)) { return; } const component = this.getCurrent(); if (!component) { return; } // Look up the trap associated with the currently active component. const scope = this.focusScopes.get(component); if (!scope) { return; } const { root, fallback } = scope; // Drop the trap when the underlying DOM node vanished (dialog closed). if (!root || !root.isConnected) { this.focusScopes.delete(component); return; } const next = ev.relatedTarget; if (next instanceof HTMLElement && root.contains(next)) { return; } // Sibling-menu gate: v-autocomplete / v-select / v-combobox teleport their // dropdown to <body>, so focus moving from the input into a list item exits // the dialog's DOM subtree and would otherwise be yanked back by the trap, // immediately closing the menu. Skip the trap only when relatedTarget sits // inside a visible .v-menu overlay that is a sibling of this dialog's overlay. const dialogOverlay = root.closest(".v-overlay"); const menuOverlayContent = next instanceof HTMLElement ? next.closest(".v-overlay__content") : null; if (dialogOverlay && menuOverlayContent && menuOverlayContent instanceof HTMLElement) { const menuOverlay = menuOverlayContent.closest(".v-overlay"); if ( menuOverlay && menuOverlay.classList.contains("v-menu") && menuOverlay.parentElement === dialogOverlay.parentElement && menuOverlay.style.display !== "none" && menuOverlayContent.contains(next) ) { return; } } ev.preventDefault(); const target = (fallback && fallback.isConnected && root.contains(fallback) && fallback) || resolveFocusTarget(root) || findFocusElement(component) || root; if (!target) { return; } this.focusScopes.set(component, { root, fallback: target }); ev.preventDefault(); setTimeout(() => { setFocus(target, false, false); }, 0); } // Returns the number of views currently registered. len() { return this.scopes?.length ? this.scopes.length : 0; } // Returns the currently active view component or null if none exists. getCurrent() { if (this.scopes.length) { return this.scopes[this.scopes.length - 1]; } else { return null; } } // Returns the parent view of the currently active view or null if none exists. getParent() { if (this.scopes.length > 1) { return this.scopes[this.scopes.length - 2]; } else { return null; } } // Returns the name of the parent view component or an empty string if none exists. getParentName() { const c = this.getParent(); if (!c) { return ""; } return c?.$options?.name ? c.$options.name : ""; } // Returns the currently active view data or an empty reactive object otherwise. getData() { const c = this.getCurrent(); if (c && c.$data) { return c.$data; } else { return {}; } } // Gives focus to the specified HTML element, or the first element that matches the specified selector string. focus(el, selector, scroll) { return setFocus(el, selector, scroll); } // disableNativeZoom locks the viewport so the browser can't pinch-zoom the whole page // while an overlay (e.g. the lightbox) handles zoom itself; restoreNativeZoom restores // the saved content. Idempotent; assumes one active overlay (savedViewportContent is // app-global), which View.apply() upholds by locking only for the top-of-stack view. disableNativeZoom() { if (this.savedViewportContent) { return; } const viewport = document.querySelector(ViewportMetaSelector); if (!viewport) { return; } const content = viewport.getAttribute("content") || ""; // Nothing to restore if the base viewport already blocks zoom (e.g. the Zoom setting is off). if (content.includes(ViewportNoZoomToken)) { return; } this.savedViewportContent = content; viewport.setAttribute("content", ViewportNoZoom); } // restoreNativeZoom restores the viewport content saved by disableNativeZoom, if any. restoreNativeZoom() { if (!this.savedViewportContent) { return; } const viewport = document.querySelector(ViewportMetaSelector); if (viewport) { viewport.setAttribute("content", this.savedViewportContent); } this.savedViewportContent = ""; } // Navigates to the specified URL, optionally with a delay set in milliseconds and a blocked user interface. redirect(url, delay, blockUI) { // Return if no URL was passed. if (!url) { console.warn(`cannot redirect because no URL was specified`); return; } // Verify that the target URL is different from the current location. const link = document.createElement("a"); link.href = url; if (window.location.href === link.toString()) { console.warn(`cannot redirect to ${url} because it is the current location`); return; } // Block the user interface, if requested. if (blockUI) { $notify.blockUI(); } // Make sure navigation is allowed. this.preventNavigation = false; // Navigate to the URL, optionally with the specified delay in milliseconds. if (delay) { if (trace) { console.log(`%credirect to "${url}" (${delay}ms delay)`, "color: #F06292"); } setTimeout(() => { window.location = url; }, delay); } else { if (trace) { console.log(`%credirect to "${url}"`, "color: #F06292"); } window.location = url; } } // Returns true if the specified view component is currently inactive, e.g. hidden in the background. isHidden(c) { return !this.isActive(c); } // Returns true if the specified view component is currently active, e.g. visible in the foreground. isActive(c) { if (!c || this.isApp()) { return true; } const context = this.scopes[this.scopes.length - 1]; if (typeof c === "object") { return c === context; } else if (typeof c === "string") { return context?.$options?.name === c; } else if (typeof c === "number") { return context?.$?.uid === c; } return false; } // Returns true if no view is currently active. isRoot() { return !this.scopes.length; } // Returns true if no view or the main view of the app is currently active. isApp() { if (this.isRoot()) { return true; } const c = this.scopes[this.scopes.length - 1]; return c?.$options?.name === "App" || c?.$?.uid === 0; } // Persists batched restore data (e.g., number of items loaded, scroll offset) for the specified key. saveRestoreState(key, state) { if (!key || !state || typeof state !== "object") { return false; } const storageKey = encodeRestoreKey(key); if (!storageKey) { return false; } const payload = { ...state }; if (!Object.prototype.hasOwnProperty.call(payload, "filterKey")) { payload.filterKey = key; } payload.timestamp = Date.now(); sessionStore.set(storageKey, JSON.stringify(payload)); return true; } // Reads stored restore data without removing it. Returns undefined if none exists or the entry expired. getRestoreState(key, maxAge = restoreMaxAgeMs) { const storageKey = encodeRestoreKey(key); if (!storageKey) { return undefined; } const raw = sessionStore.get(storageKey); if (!raw) { return undefined; } try { const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object") { sessionStore.remove(storageKey); return undefined; } const ts = Number(parsed.timestamp); if (Number.isFinite(ts) && maxAge > 0 && Date.now() - ts > maxAge) { sessionStore.remove(storageKey); return undefined; } return { ...parsed }; } catch { sessionStore.remove(storageKey); return undefined; } } // Reads and removes stored restore data atomically. consumeRestoreState(key, maxAge = restoreMaxAgeMs) { const restore = this.getRestoreState(key, maxAge); const storageKey = encodeRestoreKey(key); if (storageKey) { sessionStore.remove(storageKey); } return restore; } // Removes stored restore data for the specified key. clearRestoreState(key) { const storageKey = encodeRestoreKey(key); if (!storageKey) { return false; } sessionStore.remove(storageKey); return true; } // Computes the direction of the upcoming navigation using the provided history state snapshot. prepareNavigation(state) { const nextPos = parseHistoryPosition(state); if (typeof nextPos !== "number") { this.navigation.pendingPosition = undefined; this.navigation.direction = NavigationDirection.None; this.navigation.consumed = false; return this.navigation.direction; } const current = typeof this.navigation.currentPosition === "number" ? this.navigation.currentPosition : nextPos; let direction = NavigationDirection.Replace; if (nextPos < current) { direction = NavigationDirection.Back; } else if (nextPos > current) { direction = NavigationDirection.Forward; } this.navigation.pendingPosition = nextPos; this.navigation.direction = direction; this.navigation.consumed = false; return direction; } // Commits the navigation after Vue Router resolves and updates the tracked history position. commitNavigation(state) { const nextPos = parseHistoryPosition(state); if (typeof nextPos !== "number") { this.navigation.pendingPosition = undefined; return this.navigation.currentPosition; } const current = typeof this.navigation.currentPosition === "number" ? this.navigation.currentPosition : nextPos; if (this.navigation.direction !== NavigationDirection.Back && this.navigation.direction !== NavigationDirection.Forward) { if (nextPos < current) { this.navigation.direction = NavigationDirection.Back; } else if (nextPos > current) { this.navigation.direction = NavigationDirection.Forward; } else { this.navigation.direction = NavigationDirection.Replace; } } this.navigation.currentPosition = nextPos; this.navigation.pendingPosition = undefined; return nextPos; } // Returns the last known navigation direction. navigationDirection() { return this.navigation.direction; } // True when the latest navigation moved backwards in the history stack and has not been consumed. wasBackwardNavigation() { return this.navigation.direction === NavigationDirection.Back; } // Alias retained for legacy call sites that expect a boolean guard. isBackwardNavigationActive() { return this.navigation.direction === NavigationDirection.Back; } // Marks the back-navigation flag as consumed so subsequent queries revert to the default flow. consumeBackwardNavigation() { if (this.navigation.direction === NavigationDirection.Back && !this.navigation.consumed) { this.navigation.consumed = true; return true; } return false; } // Clears the cached navigation direction once components have reacted to it. resetNavigationDirection(direction = NavigationDirection.None) { this.navigation.direction = direction; this.navigation.consumed = false; this.navigation.pendingPosition = undefined; } // Saves the window scroll position. saveWindowScrollPos(pos) { if (!isPos(pos)) { pos = { left: Math.round(window.scrollX), top: Math.round(window.scrollY) }; } // Clone and store position. window.positionToRestore = { left: pos.left, top: pos.top }; storage.set("window.scroll.pos", JSON.stringify(window.positionToRestore)); } // Removes the stored window scroll position. clearWindowScrollPos() { window.positionToRestore = undefined; storage.remove("window.scroll.pos"); } // Gets the saved window scroll position. getWindowScrollPos(pos) { if (isPos(pos)) { return pos; } let result; // 1) Try in-memory value. const mem = window.positionToRestore; if (isPos(mem)) { // Clone so clearing the original won't affect the return value. result = { left: mem.left, top: mem.top }; } else { // 2) Fallback to localStorage. const s = storage.get("window.scroll.pos"); // string or null if (s) { try { const parsed = JSON.parse(s); if (isPos(parsed)) { result = parsed; } // already a new object } catch { /* ignore parse errors */ } } } // Clear after we've safely copied the value. this.clearWindowScrollPos(); // object {x, y} or undefined if nothing saved/valid return result; } // Restores the saved window scroll position after pending requests finish. restoreWindowScrollPos(pos) { pos = this.getWindowScrollPos(pos); if (!isPos(pos)) { return; } const target = { left: pos.left, top: pos.top }; // Allow pending API calls (pagination batches) to finish before attempting to restore. const idleDelay = 72; const maxAttempts = 20; const tolerance = 2; let attempts = 0; let lastHeight = 0; const getContainer = () => document.scrollingElement || document.documentElement; const clamp = () => { const el = getContainer(); const maxX = Math.max(0, el.scrollWidth - el.clientWidth); const maxY = Math.max(0, el.scrollHeight - el.clientHeight); return { left: Math.min(Math.max(0, target.left), maxX), top: Math.min(Math.max(0, target.top), maxY), }; }; const attemptRestore = (waitForAjax) => { if (attempts >= maxAttempts) { return; } const wait = waitForAjax ? $notify.ajaxWait(idleDelay) : Promise.resolve(); wait.then(() => { window.setTimeout(() => { if (attempts >= maxAttempts) { return; } attempts++; const el = getContainer(); const { left, top } = clamp(); requestAnimationFrame(() => { requestAnimationFrame(() => { window.scrollTo({ left, top }); const currentTop = window.scrollY || window.pageYOffset; const reachedTarget = Math.abs(currentTop - target.top) <= tolerance; const newHeight = el.scrollHeight; const needsMoreContent = target.top > newHeight - el.clientHeight; const layoutChanged = newHeight !== lastHeight; lastHeight = newHeight; if (reachedTarget) { return; } const shouldWait = $notify.ajaxBusy() || needsMoreContent; attemptRestore(shouldWait || layoutChanged); }); }); }, idleDelay); }); }; lastHeight = getContainer().scrollHeight; attemptRestore(true); } } // $view is the default View instance. export const $view = new View();