1/*  DokuWiki MoaiEditor Scroll_to.js file
2    Author  : MoaiTools <info@moaitools.org>
3    License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */
4
5/* This class will scroll smoothly to a desired point in the document while
6   measuring the reported position of that point continously in order to make
7   on-the-fly corrections to the scroll, while at the same time avoiding
8   correcting too often due to most browsers stuttering badly if done so
9   when 'scroll-behavior' is set to 'smooth' (Firefox being the exception).
10
11   This is needed because oftentimes positions of DOM elements get reported
12   inaccurately if that element is far away from the visible area. Therefore
13   scrolling close to that element is needed before an accurate measurement
14   can be obtained.
15
16   Examples of innacurate position reporting can be observed in the preview
17   (due to lazy loading of resources) and in CodeMirror (due to most lines
18   in CodeMirror not being rendered before they are in the visible area).
19
20   This class would be a lot simpler if we implemented smooth scroll in
21   javascript, but we make an effort to use the native browser solution
22   (scroll-behavior: smooth) whenever possible for performance reasons,
23   even at the cost of a lot of complications due to the quirkiness of
24   different browser's implementations of native smooth scroll, even
25   in 2026.
26*/
27MoaiEditor.ScrollTo = class {
28
29    constructor () {
30
31        // Constants
32        this.timeout = 4000;                // Avoid getting stuck in an infinite loop if something goes wrong
33        this.minUpdateTime = 300;           // Avoid updating the scroll position to often to avoid stutter in browsers
34
35        // Variables
36        this.object = null;                 // Object which needs to be scrolled (requires these getters and setters: pointerEvents, scroll.top, scroll.smooth)
37        this.smooth = true;                 // Smooth scroll can be disabled (but we still get the benefit of giving the the scroll time to converge)
38        this.ended = true;                  // Flag indicating if the loop has ended
39        this.success = false;
40        this.startTime = null;              //
41        this.lastScroll = null;             // Last measured scroll
42        this.lastUpdateTime = null;         // Last time the scroll was set
43        this.sameScrollCount = null;        // Count interations without scroll change
44        this.targetScroll = null;           //
45        this.getTargetScroll = null;        // Callback to update the target scroll
46        this.i = 0;                         // Iteration counter
47    }
48
49    start() {
50        // Remove flash element
51        this.object.flash('remove');
52        // Initialize scroll variables
53        this.targetScroll = this.getClampedTargetScroll();
54        this.lastScroll = Math.floor(this.object.scroll.top);
55        // End inmediately if we are already at the correct position
56        const scroll = Math.floor(this.object.scroll.top);
57        const distance = Math.abs(scroll-this.targetScroll);
58        if (distance < 3) {
59            this.stop(true);
60            return;
61        }
62        // Initialize time variables
63        this.startTime = Date.now();
64        this.lastUpdateTime = Date.now();
65        this.sameScrollCount = 0;
66        // Enable or disable smooth scroll on the DOM element
67        this.object.scroll.smooth = this.smooth;
68        // Set the scroll on the DOM element
69        this.object.scroll.top = this.targetScroll;
70        // Prevent users from interfering during scroll
71        this.object.pointerEvents = false;
72        // Prevent other processes from interfering during scroll (this also disables the mirror-to-textarea synchronization)
73        moaiEditor.scroll.sync.disabled = true;
74        // Show autoscrolling visual indicator (to facilitate bug tracking if the loop crashes)
75        moaiEditor.layout.indicatorScrolling.style.opacity = '1';
76        // Start loop
77        this.i = 0;
78        this.ended = false;
79        this.success = false;
80        clearInterval(this.interval);
81        this.interval = setInterval(this.onInterval.bind(this), 50);
82    }
83
84    halt() {
85        // Stop when switching editors
86        clearInterval(this.interval);
87    }
88
89    stop(reached) {
90        // TODO: Prevent errors: make sure to end the loop if the user switches editors, the element disapears, or the lines get deleted
91        this.ended = true;
92        this.success = reached;
93        clearInterval(this.interval);
94        // Disable smooth scroll
95        this.object.scroll.smooth = false;
96        // Re-enable pointer events
97        this.object.pointerEvents = true;
98        // Callback
99        moaiEditor.scroll.onScrollEnd();
100    }
101
102    getClampedTargetScroll() {
103        // Clamp the target scroll to handle out-of-bounds target values (else we will never reach the target scroll)
104        // Also handle edge cases while scroll is in progress:
105        //    a) preview is updated and preview element gets removed
106        //    b) document gets edited and line number no longer exist
107        try {
108            var target = Math.floor(this.getTargetScroll());
109            target = Math.max(0, target);
110            target = Math.min(target, this.object.scroll.max);
111            return target;
112        } catch ({name, message}) {
113            clearInterval(this.interval);
114            const errorString = "moaiEditor.scrollTo: "+name+": "+message;
115            const knownError = ['TypeError'].includes(name);
116            // Show known errors only in strict mode
117            if (knownError) {
118                if (moaiEditor.strict)
119                    console.warn(errorString);
120            }
121            // Always show unknown errors
122            else {
123                if (moaiEditor.strict)
124                    console.warn(errorString);
125                else
126                    console.error(errorString);
127            }
128            return null;
129        }
130    }
131
132    same(a, b) {
133        // Compare aproximate (and not exact) scroll positions to know if they are the same,
134        // because some browsers seem to not to be pixel-perfect at scrolling to a given position.
135        if (Math.abs(a - b) < 2)
136            return true;
137        return false;
138    }
139
140    onInterval() {
141
142        // Time variables
143        this.i += 1;
144        const now = Date.now();
145        const elapsed = now - this.startTime;
146        const lastUpdateElapsed = now - this.lastUpdateTime;
147        var minUpdateTime = 0;
148        if (this.smooth)
149            minUpdateTime = this.minUpdateTime;
150
151        // Stop if too much time has elapsed
152        if (elapsed > this.timeout) {
153            this.stop(false);
154            return;
155        }
156        // Get scroll values
157        const scroll = Math.floor(this.object.scroll.top);
158        const distance = Math.abs(scroll-this.targetScroll);
159        const targetScroll = this.getClampedTargetScroll();
160
161        // Stop if the target scroll could not be obtained
162        if (targetScroll === null) {
163            clearInterval(this.interval);
164            return;
165        }
166        // Detect if scroll has stopped
167        this.sameScrollCount += 1;
168        if (!this.same (scroll,this.lastScroll))
169            this.sameScrollCount = 0;
170
171        // Stop if we reached the intended scroll and enough time has passed
172        const targetScrollReached = distance <= 2;
173        const minFramesReached = this.sameScrollCount >= 3;
174        const minTimeLastUpdateReached = lastUpdateElapsed > minUpdateTime+50;
175        if (targetScrollReached   &&   minFramesReached   &&   minTimeLastUpdateReached) {
176            this.stop(true);
177            return;
178        }
179        // Correct scroll if needed and we are close to the previous destination (to avoid browser stutter)
180        if (!this.same (targetScroll,this.targetScroll)   &&   lastUpdateElapsed > minUpdateTime   &&   distance < 600) {
181            this.object.scroll.top = targetScroll;
182            this.targetScroll = targetScroll;
183            this.lastUpdateTime = now;
184        }
185        // Remember some values
186        this.lastScroll = scroll;
187    }
188}; // End Class
189
190
191
192
193
194
195
196