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 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
- # Temporary Holding Version
1
+ # i18n-scroll-restore
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Zero-dependency, semantic heading & relative ratio scroll preservation for multi-language websites, SPA routers, and VitePress.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/i18n-scroll-restore.svg?style=flat-square&color=059669)](https://www.npmjs.com/package/i18n-scroll-restore)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
7
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-success.svg?style=flat-square)](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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ };