i18n-scroll-restore 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +175 -2
- package/package.json +59 -4
- package/src/core.js +406 -0
- package/src/index.d.ts +82 -0
- package/src/index.js +26 -0
- package/src/vue.js +140 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grzegorz Kućmierz (https://7u.pl)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# i18n-scroll-restore
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-dependency, semantic heading & relative ratio scroll preservation for multi-language websites, SPA routers, and VitePress.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/i18n-scroll-restore)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.npmjs.com/package/i18n-scroll-restore)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The Problem: The Broken i18n Reading Experience
|
|
12
|
+
|
|
13
|
+
When readers switch languages on a long article or technical manual, standard browser and router mechanisms fail:
|
|
14
|
+
|
|
15
|
+
1. **The Scroll-to-Top Drop:** The router treats the language change as a new route (e.g. `/posts/guide` $\to$ `/en/posts/guide`) and forces `window.scrollTo(0, 0)`. The reader loses their place and must scroll searchingly down the page.
|
|
16
|
+
2. **The Pixel-Coordinate Trap:** If the router retains pixel position (`scrollY: 2450px`), the reader lands on a completely mismatched paragraph because translated languages have drastically different heights:
|
|
17
|
+
- English is often 15–25% shorter than Spanish or German.
|
|
18
|
+
- Chinese and Japanese have completely different line heights and typographic rhythms.
|
|
19
|
+
- Code blocks, formula heights, and hyphenation vary across languages.
|
|
20
|
+
|
|
21
|
+
`i18n-scroll-restore` solves this with a **dual-strategy algorithm**:
|
|
22
|
+
1. **Semantic Heading Matching (Primary):** Identifies the active heading ($H_2, H_3$) currently in the reader's viewport. When the new language renders, it matches the equivalent structural heading and restores scroll position relative to your sticky navbar.
|
|
23
|
+
2. **Relative Viewport Progress Fallback (Secondary):** If no matching heading exists, it computes the exact proportional progress ($p = \frac{\text{scrollY}}{\text{scrollHeight} - \text{innerHeight}}$) and projects it onto the target language's dynamic page height.
|
|
24
|
+
3. **Async DOM Reflow Resilience:** Markdown content frequently loads formulas (MathJax, KaTeX), diagrams (Mermaid), custom fonts, and lazy images asynchronously. An adaptive `requestAnimationFrame` loop accommodates content reflows (~500ms headroom) and instantly unlocks if the user touches the screen or scrolls with their mouse wheel.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Real-World Case Studies & Production Proof
|
|
29
|
+
|
|
30
|
+
`i18n-scroll-restore` powers multilingual production platforms across Europe:
|
|
31
|
+
|
|
32
|
+
- 🚀 **[tech.7u.pl](https://tech.7u.pl)** – High-performance engineering portal published in **6 languages** (Polish, English, Spanish, French, Chinese, Hindi) with instant reading state preservation across script directions and typography densities.
|
|
33
|
+
- 🔬 **[science.7u.pl](https://science.7u.pl)** – Mathematical physics and computational journal with heavy LaTeX formulas, 3D Canvas visualizers, and bilingual translations.
|
|
34
|
+
- 🛡️ **[safe.7u.pl](https://safe.7u.pl)** – Privacy, digital hygiene, and telemetry analysis portal with dual-tier high-contrast theming.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# npm
|
|
42
|
+
npm install i18n-scroll-restore
|
|
43
|
+
|
|
44
|
+
# pnpm
|
|
45
|
+
pnpm add i18n-scroll-restore
|
|
46
|
+
|
|
47
|
+
# yarn
|
|
48
|
+
yarn add i18n-scroll-restore
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. VitePress Integration (1 Line)
|
|
56
|
+
|
|
57
|
+
In your `.vitepress/theme/Layout.vue` or custom theme setup:
|
|
58
|
+
|
|
59
|
+
```vue
|
|
60
|
+
<script setup>
|
|
61
|
+
import { onMounted } from 'vue';
|
|
62
|
+
import { useRouter } from 'vitepress';
|
|
63
|
+
import DefaultTheme from 'vitepress/theme';
|
|
64
|
+
import { setupVitePressScrollRestore } from 'i18n-scroll-restore/vue';
|
|
65
|
+
|
|
66
|
+
const { Layout } = DefaultTheme;
|
|
67
|
+
const router = useRouter();
|
|
68
|
+
|
|
69
|
+
onMounted(() => {
|
|
70
|
+
// Automatically attaches click intercepts on language switchers
|
|
71
|
+
// and restores reading position on route change
|
|
72
|
+
setupVitePressScrollRestore({ router });
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<Layout />
|
|
78
|
+
</template>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 2. Vue 3 Composable (`useI18nScrollRestore`)
|
|
84
|
+
|
|
85
|
+
```vue
|
|
86
|
+
<script setup>
|
|
87
|
+
import { useI18nScrollRestore } from 'i18n-scroll-restore/vue';
|
|
88
|
+
|
|
89
|
+
const { save, restore, isRestoring } = useI18nScrollRestore({
|
|
90
|
+
headerOffset: 80, // Height of your sticky navbar in px
|
|
91
|
+
headingSelector: 'main h1, main h2, main h3'
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const switchLanguage = async (newLocale) => {
|
|
95
|
+
// Capture current heading + relative ratio before route switch
|
|
96
|
+
save();
|
|
97
|
+
|
|
98
|
+
await changeLocale(newLocale);
|
|
99
|
+
|
|
100
|
+
// Smoothly or instantly restore in the new language DOM
|
|
101
|
+
await restore();
|
|
102
|
+
};
|
|
103
|
+
</script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### 3. Vue Router 4 Setup
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { createRouter, createWebHistory } from 'vue-router';
|
|
112
|
+
import { setupVitePressScrollRestore } from 'i18n-scroll-restore/vue';
|
|
113
|
+
|
|
114
|
+
const router = createRouter({
|
|
115
|
+
history: createWebHistory(),
|
|
116
|
+
routes: [/* ... */]
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Automatically captures scroll before language hops and restores on mount
|
|
120
|
+
setupVitePressScrollRestore({ router });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### 4. Vanilla JavaScript / Headless Frameworks (Astro, Svelte, React, Next.js)
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
import {
|
|
129
|
+
captureScrollSnapshot,
|
|
130
|
+
restoreScrollPosition
|
|
131
|
+
} from 'i18n-scroll-restore';
|
|
132
|
+
|
|
133
|
+
// 1. Right before switching language / pushing new state:
|
|
134
|
+
captureScrollSnapshot({ headerOffset: 72 });
|
|
135
|
+
|
|
136
|
+
// 2. In your new page render callback / componentDidMount:
|
|
137
|
+
restoreScrollPosition();
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Configuration Options
|
|
143
|
+
|
|
144
|
+
| Option | Type | Default | Description |
|
|
145
|
+
| :--- | :--- | :--- | :--- |
|
|
146
|
+
| `headerOffset` | `number` | `72` | Height in pixels of your fixed/sticky top navigation bar. |
|
|
147
|
+
| `headingSelector` | `string` | `'h1, h2, h3, h4, h5, h6, [data-i18n-anchor]'` | CSS selector used to identify semantic anchors. |
|
|
148
|
+
| `behavior` | `'instant' \| 'smooth'` | `'instant'` | Scroll behavior during restoration. Instant is recommended to prevent visible motion sickness. |
|
|
149
|
+
| `maxAttempts` | `number` | `35` | RAF frame budget (~550ms at 60fps) to allow async formulas and images to settle. |
|
|
150
|
+
| `storagePrefix` | `string` | `'i18n_sr_'` | Storage prefix key for `sessionStorage`. |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Custom Heading Anchoring
|
|
155
|
+
|
|
156
|
+
If your headings differ between translations or you want to anchor by explicit custom milestones, add the `data-i18n-anchor` attribute to any element:
|
|
157
|
+
|
|
158
|
+
```html
|
|
159
|
+
<section data-i18n-anchor="benchmarks">
|
|
160
|
+
<!-- When this section is in viewport, switching language will find the matching anchor -->
|
|
161
|
+
</section>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Architecture: Zero User Hijacking
|
|
167
|
+
|
|
168
|
+
`i18n-scroll-restore` adheres to strict non-intrusive ergonomics:
|
|
169
|
+
- If the user touches the touchscreen (`touchstart`) or spins the mouse wheel (`wheel`) during restoration, the process **immediately aborts** and relinquishes full control to the user.
|
|
170
|
+
- No permanent locks, no synthetic scroll delays.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
[MIT](LICENSE) © 2026 Grzegorz Kućmierz (https://7u.pl)
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-scroll-restore",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Zero-dependency, semantic heading & relative ratio scroll preservation for multi-language sites, SPA routers, and VitePress.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.js",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./vue": {
|
|
16
|
+
"types": "./src/index.d.ts",
|
|
17
|
+
"import": "./src/vue.js",
|
|
18
|
+
"default": "./src/vue.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test tests/**/*.test.js"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"i18n",
|
|
31
|
+
"scroll",
|
|
32
|
+
"scroll-restoration",
|
|
33
|
+
"scroll-restore",
|
|
34
|
+
"vue",
|
|
35
|
+
"vue3",
|
|
36
|
+
"vitepress",
|
|
37
|
+
"docusaurus",
|
|
38
|
+
"nuxt",
|
|
39
|
+
"nextjs",
|
|
40
|
+
"internationalization",
|
|
41
|
+
"reading-position",
|
|
42
|
+
"anchor",
|
|
43
|
+
"headings",
|
|
44
|
+
"zero-dependency"
|
|
45
|
+
],
|
|
46
|
+
"author": "Grzegorz Kućmierz <gkucmierz@gmail.com> (https://7u.pl)",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://gitea.7u.pl/gkucmierz/i18n-scroll-restore.git"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://gitea.7u.pl/gkucmierz/i18n-scroll-restore",
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"vue": ">=3.0.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"vue": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n-scroll-restore
|
|
3
|
+
* Core zero-dependency scroll preservation engine for multi-language sites and SPAs.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Semantic heading hierarchy & index matching
|
|
7
|
+
* - Relative viewport ratio progress calculation
|
|
8
|
+
* - Custom scrollable DOM containers (e.g. split-pane studios, app shells) as well as window
|
|
9
|
+
* - Async DOM layout reflow resilience (Mermaid, MathJax, fonts, lazy images)
|
|
10
|
+
* - Non-intrusive cancellation on user interaction
|
|
11
|
+
*
|
|
12
|
+
* @author Grzegorz Kućmierz <gkucmierz@gmail.com> (https://7u.pl)
|
|
13
|
+
* @license MIT
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STORAGE_PREFIX = 'i18n_sr_';
|
|
17
|
+
const DEFAULT_HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, [data-i18n-anchor]';
|
|
18
|
+
const DEFAULT_HEADER_OFFSET = 72; // px headroom for fixed/sticky navbar
|
|
19
|
+
const MAX_ATTEMPTS = 35; // ~550ms headroom for DOM reflow
|
|
20
|
+
|
|
21
|
+
let activeSnapshot = null;
|
|
22
|
+
let isRestoring = false;
|
|
23
|
+
let userCancelled = false;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves an optional custom scrollable container element (`Element`, selector string, or getter function).
|
|
27
|
+
* Returns null when scrolling the global `window`.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @returns {Element|null}
|
|
31
|
+
*/
|
|
32
|
+
export const resolveScrollContainer = (options = {}) => {
|
|
33
|
+
if (!options || !options.container) return null;
|
|
34
|
+
const raw = typeof options.container === 'function' ? options.container() : options.container;
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
if (typeof raw === 'string') {
|
|
37
|
+
if (typeof document !== 'undefined' && typeof document.querySelector === 'function') {
|
|
38
|
+
return document.querySelector(raw);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
// Support Vue template refs (`ref.value` or direct Element)
|
|
43
|
+
if (raw && typeof raw === 'object' && 'value' in raw) {
|
|
44
|
+
return raw.value || null;
|
|
45
|
+
}
|
|
46
|
+
return raw;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getStorageBackend = (options = {}) => {
|
|
50
|
+
if (typeof window === 'undefined') return null;
|
|
51
|
+
if (options.persist && typeof localStorage !== 'undefined') return localStorage;
|
|
52
|
+
if (typeof sessionStorage !== 'undefined') return sessionStorage;
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculates relative scroll progress between 0 and 1.
|
|
58
|
+
*
|
|
59
|
+
* @param {number} scrollY
|
|
60
|
+
* @param {number} scrollHeight
|
|
61
|
+
* @param {number} innerHeight
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
export const calculateRelativeProgress = (scrollY, scrollHeight, innerHeight) => {
|
|
65
|
+
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
|
66
|
+
if (maxScroll <= 0) return 0;
|
|
67
|
+
const ratio = scrollY / maxScroll;
|
|
68
|
+
return Math.min(1, Math.max(0, ratio));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Calculates target scroll position in pixels from relative progress.
|
|
73
|
+
*
|
|
74
|
+
* @param {number} ratio
|
|
75
|
+
* @param {number} scrollHeight
|
|
76
|
+
* @param {number} innerHeight
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
export const calculateTargetScroll = (ratio, scrollHeight, innerHeight) => {
|
|
80
|
+
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
|
81
|
+
return Math.round(Math.min(1, Math.max(0, ratio)) * maxScroll);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Finds the currently active heading closest to the top reading area.
|
|
86
|
+
*
|
|
87
|
+
* @param {Document|Element} root
|
|
88
|
+
* @param {object} options
|
|
89
|
+
* @returns {object|null}
|
|
90
|
+
*/
|
|
91
|
+
export const findActiveHeading = (root = (typeof document !== 'undefined' ? document : null), options = {}) => {
|
|
92
|
+
const containerEl = resolveScrollContainer(options);
|
|
93
|
+
const queryRoot = containerEl || root;
|
|
94
|
+
if (!queryRoot || typeof queryRoot.querySelectorAll !== 'function') return null;
|
|
95
|
+
|
|
96
|
+
const selector = options.headingSelector || DEFAULT_HEADING_SELECTOR;
|
|
97
|
+
const containerTop =
|
|
98
|
+
containerEl && typeof containerEl.getBoundingClientRect === 'function'
|
|
99
|
+
? containerEl.getBoundingClientRect().top
|
|
100
|
+
: 0;
|
|
101
|
+
const headerOffset = containerTop + (options.headerOffset ?? (containerEl ? 16 : DEFAULT_HEADER_OFFSET));
|
|
102
|
+
const headings = Array.from(queryRoot.querySelectorAll(selector)).filter((el) => {
|
|
103
|
+
return el.offsetParent !== null && el.getBoundingClientRect().height > 0;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (headings.length === 0) return null;
|
|
107
|
+
|
|
108
|
+
let bestHeading = null;
|
|
109
|
+
let bestDistance = Infinity;
|
|
110
|
+
|
|
111
|
+
headings.forEach((heading) => {
|
|
112
|
+
const rect = heading.getBoundingClientRect();
|
|
113
|
+
const distance = rect.top - headerOffset;
|
|
114
|
+
|
|
115
|
+
if (distance <= 120 && Math.abs(distance) < bestDistance) {
|
|
116
|
+
bestDistance = Math.abs(distance);
|
|
117
|
+
bestHeading = heading;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!bestHeading) {
|
|
122
|
+
for (let i = headings.length - 1; i >= 0; i--) {
|
|
123
|
+
const rect = headings[i].getBoundingClientRect();
|
|
124
|
+
if (rect.top - headerOffset <= 200) {
|
|
125
|
+
bestHeading = headings[i];
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!bestHeading) {
|
|
132
|
+
bestHeading = headings[0];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const tag = bestHeading.tagName ? bestHeading.tagName.toUpperCase() : 'H2';
|
|
136
|
+
const tagList = headings.filter((h) => (h.tagName ? h.tagName.toUpperCase() : '') === tag);
|
|
137
|
+
const indexInTag = tagList.indexOf(bestHeading);
|
|
138
|
+
const indexOverall = headings.indexOf(bestHeading);
|
|
139
|
+
const rect = bestHeading.getBoundingClientRect();
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
tag,
|
|
143
|
+
indexInTag: indexInTag >= 0 ? indexInTag : 0,
|
|
144
|
+
indexOverall: indexOverall >= 0 ? indexOverall : 0,
|
|
145
|
+
id: bestHeading.id || null,
|
|
146
|
+
topOffset: Math.round(rect.top - headerOffset)
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Creates a complete snapshot of current scroll & reading context.
|
|
152
|
+
*
|
|
153
|
+
* @param {object} options
|
|
154
|
+
* @returns {object|null}
|
|
155
|
+
*/
|
|
156
|
+
export const captureScrollSnapshot = (options = {}) => {
|
|
157
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
|
158
|
+
|
|
159
|
+
const containerEl = resolveScrollContainer(options);
|
|
160
|
+
const scrollY = containerEl ? containerEl.scrollTop || 0 : window.scrollY || window.pageYOffset || 0;
|
|
161
|
+
const scrollX = containerEl ? containerEl.scrollLeft || 0 : window.scrollX || window.pageXOffset || 0;
|
|
162
|
+
const scrollHeight = containerEl
|
|
163
|
+
? containerEl.scrollHeight || 0
|
|
164
|
+
: Math.max(
|
|
165
|
+
document.documentElement.scrollHeight,
|
|
166
|
+
document.body ? document.body.scrollHeight : 0
|
|
167
|
+
);
|
|
168
|
+
const innerHeight = containerEl ? containerEl.clientHeight || 800 : window.innerHeight || 800;
|
|
169
|
+
|
|
170
|
+
const relativeProgress = calculateRelativeProgress(scrollY, scrollHeight, innerHeight);
|
|
171
|
+
const heading = findActiveHeading(containerEl || document, options);
|
|
172
|
+
|
|
173
|
+
const snapshot = {
|
|
174
|
+
scrollY,
|
|
175
|
+
scrollX,
|
|
176
|
+
scrollHeight,
|
|
177
|
+
innerHeight,
|
|
178
|
+
relativeProgress,
|
|
179
|
+
heading,
|
|
180
|
+
timestamp: Date.now()
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
activeSnapshot = snapshot;
|
|
184
|
+
|
|
185
|
+
const prefix = options.storagePrefix || DEFAULT_STORAGE_PREFIX;
|
|
186
|
+
const store = getStorageBackend(options);
|
|
187
|
+
try {
|
|
188
|
+
if (store) store.setItem(`${prefix}snapshot`, JSON.stringify(snapshot));
|
|
189
|
+
} catch (e) {}
|
|
190
|
+
|
|
191
|
+
return snapshot;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Retrieves the last captured snapshot from memory or sessionStorage/localStorage.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} options
|
|
198
|
+
* @returns {object|null}
|
|
199
|
+
*/
|
|
200
|
+
export const getSavedSnapshot = (options = {}) => {
|
|
201
|
+
if (activeSnapshot && !options.storagePrefix) return activeSnapshot;
|
|
202
|
+
|
|
203
|
+
if (typeof window === 'undefined') return null;
|
|
204
|
+
const prefix = options.storagePrefix || DEFAULT_STORAGE_PREFIX;
|
|
205
|
+
const store = getStorageBackend(options);
|
|
206
|
+
try {
|
|
207
|
+
const raw = store ? store.getItem(`${prefix}snapshot`) : null;
|
|
208
|
+
if (raw) {
|
|
209
|
+
const parsed = JSON.parse(raw);
|
|
210
|
+
activeSnapshot = parsed;
|
|
211
|
+
return parsed;
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
|
|
215
|
+
return activeSnapshot;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Clears the saved snapshot from memory and storage.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} options
|
|
222
|
+
*/
|
|
223
|
+
export const clearSavedSnapshot = (options = {}) => {
|
|
224
|
+
activeSnapshot = null;
|
|
225
|
+
if (typeof window === 'undefined') return;
|
|
226
|
+
const prefix = options.storagePrefix || DEFAULT_STORAGE_PREFIX;
|
|
227
|
+
const store = getStorageBackend(options);
|
|
228
|
+
try {
|
|
229
|
+
if (store) store.removeItem(`${prefix}snapshot`);
|
|
230
|
+
} catch (e) {}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolves the target scroll position in the current DOM from a snapshot.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} snapshot
|
|
237
|
+
* @param {object} options
|
|
238
|
+
* @returns {number}
|
|
239
|
+
*/
|
|
240
|
+
export const resolveTargetScrollPosition = (snapshot, options = {}) => {
|
|
241
|
+
if (!snapshot || typeof window === 'undefined' || typeof document === 'undefined') return 0;
|
|
242
|
+
|
|
243
|
+
const containerEl = resolveScrollContainer(options);
|
|
244
|
+
const queryRoot = containerEl || document;
|
|
245
|
+
const containerTop =
|
|
246
|
+
containerEl && typeof containerEl.getBoundingClientRect === 'function'
|
|
247
|
+
? containerEl.getBoundingClientRect().top
|
|
248
|
+
: 0;
|
|
249
|
+
const headerOffset = containerTop + (options.headerOffset ?? (containerEl ? 16 : DEFAULT_HEADER_OFFSET));
|
|
250
|
+
const selector = options.headingSelector || DEFAULT_HEADING_SELECTOR;
|
|
251
|
+
const scrollHeight = containerEl
|
|
252
|
+
? containerEl.scrollHeight || 0
|
|
253
|
+
: Math.max(
|
|
254
|
+
document.documentElement.scrollHeight,
|
|
255
|
+
document.body ? document.body.scrollHeight : 0
|
|
256
|
+
);
|
|
257
|
+
const innerHeight = containerEl ? containerEl.clientHeight || 800 : window.innerHeight || 800;
|
|
258
|
+
|
|
259
|
+
// 1. Semantic Heading Match
|
|
260
|
+
if (snapshot.heading && typeof queryRoot.querySelectorAll === 'function') {
|
|
261
|
+
const headings = Array.from(queryRoot.querySelectorAll(selector)).filter((el) => {
|
|
262
|
+
return el.offsetParent !== null && el.getBoundingClientRect().height > 0;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (headings.length > 0) {
|
|
266
|
+
let matched = null;
|
|
267
|
+
|
|
268
|
+
if (snapshot.heading.id && typeof document.getElementById === 'function') {
|
|
269
|
+
matched = document.getElementById(snapshot.heading.id);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!matched && snapshot.heading.tag) {
|
|
273
|
+
const sameTagList = headings.filter(
|
|
274
|
+
(h) => (h.tagName ? h.tagName.toUpperCase() : '') === snapshot.heading.tag
|
|
275
|
+
);
|
|
276
|
+
if (sameTagList[snapshot.heading.indexInTag]) {
|
|
277
|
+
matched = sameTagList[snapshot.heading.indexInTag];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!matched && headings[snapshot.heading.indexOverall]) {
|
|
282
|
+
matched = headings[snapshot.heading.indexOverall];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (matched) {
|
|
286
|
+
const rect = matched.getBoundingClientRect();
|
|
287
|
+
const currentScroll = containerEl
|
|
288
|
+
? containerEl.scrollTop || 0
|
|
289
|
+
: window.scrollY || window.pageYOffset || 0;
|
|
290
|
+
const savedTopOffset = typeof snapshot.heading.topOffset === 'number' ? snapshot.heading.topOffset : 0;
|
|
291
|
+
const targetY = currentScroll + rect.top - headerOffset - savedTopOffset;
|
|
292
|
+
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
|
293
|
+
return Math.round(Math.min(maxScroll, Math.max(0, targetY)));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 2. Relative Ratio Fallback
|
|
299
|
+
return calculateTargetScroll(snapshot.relativeProgress || 0, scrollHeight, innerHeight);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Restores the scroll position smoothly or instantly with layout reflow resilience.
|
|
304
|
+
*
|
|
305
|
+
* @param {object} [customSnapshot]
|
|
306
|
+
* @param {object} [options]
|
|
307
|
+
* @returns {Promise<boolean>}
|
|
308
|
+
*/
|
|
309
|
+
export const restoreScrollPosition = (customSnapshot = null, options = {}) => {
|
|
310
|
+
if (typeof window === 'undefined') return Promise.resolve(false);
|
|
311
|
+
|
|
312
|
+
const snapshot = customSnapshot || getSavedSnapshot(options);
|
|
313
|
+
if (!snapshot) return Promise.resolve(false);
|
|
314
|
+
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
isRestoring = true;
|
|
317
|
+
userCancelled = false;
|
|
318
|
+
|
|
319
|
+
let attempts = 0;
|
|
320
|
+
const maxAttempts = options.maxAttempts || MAX_ATTEMPTS;
|
|
321
|
+
|
|
322
|
+
const cancel = () => {
|
|
323
|
+
userCancelled = true;
|
|
324
|
+
isRestoring = false;
|
|
325
|
+
cleanup();
|
|
326
|
+
resolve(false);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const cleanup = () => {
|
|
330
|
+
window.removeEventListener('wheel', cancel);
|
|
331
|
+
window.removeEventListener('touchstart', cancel);
|
|
332
|
+
window.removeEventListener('keydown', cancel);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
window.addEventListener('wheel', cancel, { passive: true });
|
|
336
|
+
window.addEventListener('touchstart', cancel, { passive: true });
|
|
337
|
+
window.addEventListener('keydown', cancel, { passive: true });
|
|
338
|
+
|
|
339
|
+
let consecutiveStableFrames = 0;
|
|
340
|
+
|
|
341
|
+
const applyScrollTop = (targetY) => {
|
|
342
|
+
const containerEl = resolveScrollContainer(options);
|
|
343
|
+
if (containerEl) {
|
|
344
|
+
if (typeof containerEl.scrollTo === 'function') {
|
|
345
|
+
containerEl.scrollTo({
|
|
346
|
+
top: targetY,
|
|
347
|
+
left: 0,
|
|
348
|
+
behavior: options.behavior || 'instant'
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
containerEl.scrollTop = targetY;
|
|
352
|
+
}
|
|
353
|
+
return containerEl.scrollTop || 0;
|
|
354
|
+
}
|
|
355
|
+
window.scrollTo({
|
|
356
|
+
top: targetY,
|
|
357
|
+
left: 0,
|
|
358
|
+
behavior: options.behavior || 'instant'
|
|
359
|
+
});
|
|
360
|
+
return window.scrollY || window.pageYOffset || 0;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const tick = () => {
|
|
364
|
+
if (userCancelled || !isRestoring) {
|
|
365
|
+
cleanup();
|
|
366
|
+
resolve(false);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const targetY = resolveTargetScrollPosition(snapshot, options);
|
|
371
|
+
const actualY = applyScrollTop(targetY);
|
|
372
|
+
|
|
373
|
+
attempts++;
|
|
374
|
+
const diff = Math.abs(actualY - targetY);
|
|
375
|
+
|
|
376
|
+
if (diff <= 2) {
|
|
377
|
+
consecutiveStableFrames++;
|
|
378
|
+
} else {
|
|
379
|
+
consecutiveStableFrames = 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (consecutiveStableFrames >= 2 || attempts >= maxAttempts) {
|
|
383
|
+
applyScrollTop(targetY);
|
|
384
|
+
setTimeout(() => {
|
|
385
|
+
isRestoring = false;
|
|
386
|
+
cleanup();
|
|
387
|
+
if (!options.persist) {
|
|
388
|
+
clearSavedSnapshot(options);
|
|
389
|
+
}
|
|
390
|
+
resolve(true);
|
|
391
|
+
}, 50);
|
|
392
|
+
} else {
|
|
393
|
+
requestAnimationFrame(tick);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
requestAnimationFrame(tick);
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Returns whether a scroll restoration sequence is currently active.
|
|
403
|
+
*
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
export const isScrollRestoring = () => isRestoring;
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface HeadingAnchor {
|
|
2
|
+
tag: string;
|
|
3
|
+
indexInTag: number;
|
|
4
|
+
indexOverall: number;
|
|
5
|
+
id: string | null;
|
|
6
|
+
topOffset: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface I18nScrollSnapshot {
|
|
10
|
+
scrollY: number;
|
|
11
|
+
scrollX: number;
|
|
12
|
+
scrollHeight: number;
|
|
13
|
+
innerHeight: number;
|
|
14
|
+
relativeProgress: number;
|
|
15
|
+
heading: HeadingAnchor | null;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ScrollRestoreOptions {
|
|
20
|
+
storagePrefix?: string;
|
|
21
|
+
headingSelector?: string;
|
|
22
|
+
headerOffset?: number;
|
|
23
|
+
maxAttempts?: number;
|
|
24
|
+
behavior?: ScrollBehavior;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function calculateRelativeProgress(
|
|
28
|
+
scrollY: number,
|
|
29
|
+
scrollHeight: number,
|
|
30
|
+
innerHeight: number
|
|
31
|
+
): number;
|
|
32
|
+
|
|
33
|
+
export function calculateTargetScroll(
|
|
34
|
+
ratio: number,
|
|
35
|
+
scrollHeight: number,
|
|
36
|
+
innerHeight: number
|
|
37
|
+
): number;
|
|
38
|
+
|
|
39
|
+
export function findActiveHeading(
|
|
40
|
+
root?: Document | Element | null,
|
|
41
|
+
options?: ScrollRestoreOptions
|
|
42
|
+
): HeadingAnchor | null;
|
|
43
|
+
|
|
44
|
+
export function captureScrollSnapshot(
|
|
45
|
+
options?: ScrollRestoreOptions
|
|
46
|
+
): I18nScrollSnapshot | null;
|
|
47
|
+
|
|
48
|
+
export function getSavedSnapshot(
|
|
49
|
+
options?: ScrollRestoreOptions
|
|
50
|
+
): I18nScrollSnapshot | null;
|
|
51
|
+
|
|
52
|
+
export function clearSavedSnapshot(
|
|
53
|
+
options?: ScrollRestoreOptions
|
|
54
|
+
): void;
|
|
55
|
+
|
|
56
|
+
export function resolveTargetScrollPosition(
|
|
57
|
+
snapshot: I18nScrollSnapshot | null,
|
|
58
|
+
options?: ScrollRestoreOptions
|
|
59
|
+
): number;
|
|
60
|
+
|
|
61
|
+
export function restoreScrollPosition(
|
|
62
|
+
customSnapshot?: I18nScrollSnapshot | null,
|
|
63
|
+
options?: ScrollRestoreOptions
|
|
64
|
+
): Promise<boolean>;
|
|
65
|
+
|
|
66
|
+
export function isScrollRestoring(): boolean;
|
|
67
|
+
|
|
68
|
+
export function useI18nScrollRestore(options?: ScrollRestoreOptions): {
|
|
69
|
+
save: () => I18nScrollSnapshot | null;
|
|
70
|
+
restore: (customSnapshot?: I18nScrollSnapshot | null) => Promise<boolean>;
|
|
71
|
+
clear: () => void;
|
|
72
|
+
isRestoring: () => boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function setupVitePressScrollRestore(params?: {
|
|
76
|
+
router?: any;
|
|
77
|
+
options?: ScrollRestoreOptions;
|
|
78
|
+
}): () => void;
|
|
79
|
+
|
|
80
|
+
export function createI18nScrollRestorePlugin(defaultOptions?: ScrollRestoreOptions): {
|
|
81
|
+
install(app: any, options?: ScrollRestoreOptions): void;
|
|
82
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n-scroll-restore
|
|
3
|
+
* Entry point for vanilla JS and bundlers.
|
|
4
|
+
*
|
|
5
|
+
* @author Grzegorz Kućmierz <gkucmierz@gmail.com> (https://7u.pl)
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
resolveScrollContainer,
|
|
11
|
+
calculateRelativeProgress,
|
|
12
|
+
calculateTargetScroll,
|
|
13
|
+
findActiveHeading,
|
|
14
|
+
captureScrollSnapshot,
|
|
15
|
+
getSavedSnapshot,
|
|
16
|
+
clearSavedSnapshot,
|
|
17
|
+
resolveTargetScrollPosition,
|
|
18
|
+
restoreScrollPosition,
|
|
19
|
+
isScrollRestoring
|
|
20
|
+
} from './core.js';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
useI18nScrollRestore,
|
|
24
|
+
setupVitePressScrollRestore,
|
|
25
|
+
createI18nScrollRestorePlugin
|
|
26
|
+
} from './vue.js';
|
package/src/vue.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n-scroll-restore/vue
|
|
3
|
+
* Vue 3 and VitePress composable & integration helper.
|
|
4
|
+
*
|
|
5
|
+
* @author Grzegorz Kućmierz <gkucmierz@gmail.com> (https://7u.pl)
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
captureScrollSnapshot,
|
|
11
|
+
restoreScrollPosition,
|
|
12
|
+
clearSavedSnapshot,
|
|
13
|
+
isScrollRestoring
|
|
14
|
+
} from './core.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vue 3 Composable for i18n scroll preservation.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @returns {object}
|
|
21
|
+
*/
|
|
22
|
+
export const useI18nScrollRestore = (options = {}) => {
|
|
23
|
+
const save = () => captureScrollSnapshot(options);
|
|
24
|
+
const restore = (customSnapshot = null) => restoreScrollPosition(customSnapshot, options);
|
|
25
|
+
const clear = () => clearSavedSnapshot(options);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
save,
|
|
29
|
+
restore,
|
|
30
|
+
clear,
|
|
31
|
+
isRestoring: isScrollRestoring
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sets up automatic scroll restoration for VitePress or Vue Router.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} params
|
|
39
|
+
* @param {object} [params.router] Vue Router or VitePress router instance
|
|
40
|
+
* @param {object} [params.options] Scroll restore configuration options
|
|
41
|
+
* @returns {function} Cleanup function
|
|
42
|
+
*/
|
|
43
|
+
export const setupVitePressScrollRestore = ({ router, options = {} } = {}) => {
|
|
44
|
+
if (typeof window === 'undefined') return () => {};
|
|
45
|
+
|
|
46
|
+
let lastPath = window.location.pathname;
|
|
47
|
+
|
|
48
|
+
// Intercept before language / route transition
|
|
49
|
+
const handleBeforeNavigate = () => {
|
|
50
|
+
captureScrollSnapshot(options);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Watch clicks on language links (e.g. .VPNavBarTranslations, flags, [data-i18n-link])
|
|
54
|
+
const handleDocumentClick = (event) => {
|
|
55
|
+
const target = event.target;
|
|
56
|
+
if (!target || typeof target.closest !== 'function') return;
|
|
57
|
+
|
|
58
|
+
const link = target.closest('a, button.VPLink, .VPNavBarTranslations a, .VPNavScreenTranslations a');
|
|
59
|
+
if (link) {
|
|
60
|
+
captureScrollSnapshot(options);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
document.addEventListener('click', handleDocumentClick, { capture: true, passive: true });
|
|
65
|
+
window.addEventListener('beforeunload', handleBeforeNavigate);
|
|
66
|
+
window.addEventListener('pagehide', handleBeforeNavigate);
|
|
67
|
+
|
|
68
|
+
let unwatchRouter = null;
|
|
69
|
+
|
|
70
|
+
if (router && typeof router.onAfterRouteChanged === 'function') {
|
|
71
|
+
// VitePress Router
|
|
72
|
+
router.onBeforeRouteChange = () => {
|
|
73
|
+
captureScrollSnapshot(options);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const originalAfter = router.onAfterRouteChanged;
|
|
77
|
+
router.onAfterRouteChanged = (to) => {
|
|
78
|
+
if (typeof originalAfter === 'function') {
|
|
79
|
+
originalAfter(to);
|
|
80
|
+
}
|
|
81
|
+
// Small tick to let Vue mount the new content
|
|
82
|
+
requestAnimationFrame(() => {
|
|
83
|
+
restoreScrollPosition(null, options);
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
unwatchRouter = () => {
|
|
88
|
+
router.onAfterRouteChanged = originalAfter;
|
|
89
|
+
};
|
|
90
|
+
} else if (router && typeof router.beforeEach === 'function') {
|
|
91
|
+
// Standard Vue Router 4
|
|
92
|
+
const removeBefore = router.beforeEach((to, from, next) => {
|
|
93
|
+
if (to.path !== from.path) {
|
|
94
|
+
captureScrollSnapshot(options);
|
|
95
|
+
}
|
|
96
|
+
next();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const removeAfter = router.afterEach(() => {
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
restoreScrollPosition(null, options);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
unwatchRouter = () => {
|
|
106
|
+
removeBefore();
|
|
107
|
+
removeAfter();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Initial mount restoration if returning to page or switching
|
|
112
|
+
requestAnimationFrame(() => {
|
|
113
|
+
restoreScrollPosition(null, options);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
|
118
|
+
window.removeEventListener('beforeunload', handleBeforeNavigate);
|
|
119
|
+
window.removeEventListener('pagehide', handleBeforeNavigate);
|
|
120
|
+
if (unwatchRouter) unwatchRouter();
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Vue 3 Plugin wrapper.
|
|
126
|
+
*/
|
|
127
|
+
export const createI18nScrollRestorePlugin = (defaultOptions = {}) => {
|
|
128
|
+
return {
|
|
129
|
+
install(app, options = {}) {
|
|
130
|
+
const mergedOptions = { ...defaultOptions, ...options };
|
|
131
|
+
app.config.globalProperties.$i18nScrollRestore = {
|
|
132
|
+
save: () => captureScrollSnapshot(mergedOptions),
|
|
133
|
+
restore: (snap) => restoreScrollPosition(snap, mergedOptions),
|
|
134
|
+
clear: () => clearSavedSnapshot(mergedOptions),
|
|
135
|
+
isRestoring: isScrollRestoring
|
|
136
|
+
};
|
|
137
|
+
app.provide('i18nScrollRestore', app.config.globalProperties.$i18nScrollRestore);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
};
|