@abreen/tada 1.19.2 → 1.19.3
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/build/watch/engine.ts +62 -6
- package/package.json +1 -1
- package/src/navigate/runtime.ts +66 -5
- package/src/toc/_index.scss +6 -1
package/build/watch/engine.ts
CHANGED
|
@@ -22,6 +22,10 @@ export function runWatchEngine<Meta>(
|
|
|
22
22
|
const debounceMs = options.debounceMs ?? 300;
|
|
23
23
|
const watchers: ReturnType<typeof chokidar.watch>[] = [];
|
|
24
24
|
const pending = new Set<string>();
|
|
25
|
+
const removed = new Map<
|
|
26
|
+
string,
|
|
27
|
+
{ watcher: ReturnType<typeof chokidar.watch>; root: string }
|
|
28
|
+
>();
|
|
25
29
|
let uncommitted = new Set<string>();
|
|
26
30
|
let closed = false;
|
|
27
31
|
let fatal: { error: unknown } | undefined;
|
|
@@ -33,6 +37,7 @@ export function runWatchEngine<Meta>(
|
|
|
33
37
|
function stop(): void {
|
|
34
38
|
closed = true;
|
|
35
39
|
pending.clear();
|
|
40
|
+
removed.clear();
|
|
36
41
|
if (timer !== undefined) {
|
|
37
42
|
clock.clearTimeout(timer);
|
|
38
43
|
}
|
|
@@ -78,6 +83,52 @@ export function runWatchEngine<Meta>(
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
function isFile(filePath: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
return dependencies.stat(filePath)?.isFile() ?? false;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOTDIR') {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isInside(root: string, filePath: string): boolean {
|
|
98
|
+
const relative = path.relative(root, filePath);
|
|
99
|
+
return (
|
|
100
|
+
relative !== '' &&
|
|
101
|
+
relative !== '..' &&
|
|
102
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
103
|
+
!path.isAbsolute(relative)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Chokidar tracks entries by name, so a directory replaced by a file keeps
|
|
109
|
+
* its directory subscription and edits to the file are never reported. When
|
|
110
|
+
* a poll misses the moment between the two, the only events are removals of
|
|
111
|
+
* what was inside the directory. Once changes have settled, resubscribe any
|
|
112
|
+
* parent of a removed path that is now a file.
|
|
113
|
+
*/
|
|
114
|
+
function resubscribeReplacedDirectories(): void {
|
|
115
|
+
const checked = new Set<string>();
|
|
116
|
+
for (const [removedPath, { watcher, root }] of removed) {
|
|
117
|
+
for (
|
|
118
|
+
let dir = path.dirname(removedPath);
|
|
119
|
+
isInside(root, dir) && !checked.has(dir);
|
|
120
|
+
dir = path.dirname(dir)
|
|
121
|
+
) {
|
|
122
|
+
checked.add(dir);
|
|
123
|
+
if (isFile(dir)) {
|
|
124
|
+
watcher.unwatch(dir);
|
|
125
|
+
watcher.add(dir);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
removed.clear();
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
async function emit(event: WatchLifecycleEvent<Meta>): Promise<void> {
|
|
82
133
|
if (!closed) {
|
|
83
134
|
await options.onEvent?.(event);
|
|
@@ -139,7 +190,13 @@ export function runWatchEngine<Meta>(
|
|
|
139
190
|
fail(error);
|
|
140
191
|
}
|
|
141
192
|
};
|
|
142
|
-
|
|
193
|
+
const removedPath = (filePath: string) => {
|
|
194
|
+
if (!closed) {
|
|
195
|
+
removed.set(filePath, { watcher, root: target.path });
|
|
196
|
+
}
|
|
197
|
+
included(filePath);
|
|
198
|
+
};
|
|
199
|
+
watcher.on('add', included).on('unlink', removedPath);
|
|
143
200
|
watcher.on('change', (filePath, stats) => {
|
|
144
201
|
if (closed) {
|
|
145
202
|
return;
|
|
@@ -156,20 +213,18 @@ export function runWatchEngine<Meta>(
|
|
|
156
213
|
});
|
|
157
214
|
watcher.on('addDir', included);
|
|
158
215
|
watcher.on('unlinkDir', filePath => {
|
|
159
|
-
|
|
216
|
+
removedPath(filePath);
|
|
160
217
|
// Wait until Chokidar finishes closing the old directory subscription.
|
|
161
218
|
queueMicrotask(() => {
|
|
162
219
|
if (closed) {
|
|
163
220
|
return;
|
|
164
221
|
}
|
|
165
222
|
try {
|
|
166
|
-
if (
|
|
223
|
+
if (isFile(filePath)) {
|
|
167
224
|
watcher.add(filePath);
|
|
168
225
|
}
|
|
169
226
|
} catch (error) {
|
|
170
|
-
|
|
171
|
-
fail(error);
|
|
172
|
-
}
|
|
227
|
+
fail(error);
|
|
173
228
|
}
|
|
174
229
|
});
|
|
175
230
|
});
|
|
@@ -197,6 +252,7 @@ export function runWatchEngine<Meta>(
|
|
|
197
252
|
while (!closed && pending.size) {
|
|
198
253
|
const paths = new Set([...uncommitted, ...pending]);
|
|
199
254
|
pending.clear();
|
|
255
|
+
resubscribeReplacedDirectories();
|
|
200
256
|
const outcome = await build(paths);
|
|
201
257
|
if (!outcome?.ok) {
|
|
202
258
|
uncommitted = paths;
|
package/package.json
CHANGED
package/src/navigate/runtime.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { swapHeaderTitle } from '../header';
|
|
|
11
11
|
export const NAVIGATION_EVENT = 'tada:navigation';
|
|
12
12
|
|
|
13
13
|
const LOADING_CURSOR_DELAY = 400;
|
|
14
|
+
const HOLD_SCROLL_FRAMES = 6;
|
|
15
|
+
const HOLD_SCROLL_RELEASE_EVENTS = [
|
|
16
|
+
'wheel',
|
|
17
|
+
'touchstart',
|
|
18
|
+
'keydown',
|
|
19
|
+
'pointerdown',
|
|
20
|
+
] as const;
|
|
14
21
|
|
|
15
22
|
let currentAbortController: AbortController | null = null;
|
|
16
23
|
let historyIndex = 0;
|
|
@@ -263,33 +270,49 @@ export function isApplyingFragment(): boolean {
|
|
|
263
270
|
// WebKit defers fragment scrolling until pending stylesheets load, which would
|
|
264
271
|
// later move the page away from the restored scroll position. Wait for them
|
|
265
272
|
// first, and skip the update if another request or navigation replaced it.
|
|
266
|
-
|
|
273
|
+
//
|
|
274
|
+
// That deferred scroll can also set :target before the wait ends, which would
|
|
275
|
+
// leave nothing to replace. WebKit then scrolls to the target again on the next
|
|
276
|
+
// frame, undoing a scroll back to the saved position. A fragment navigation of
|
|
277
|
+
// our own does not have that problem, so after waiting, repeat it even when
|
|
278
|
+
// :target matches, restoring the saved position the caller passed in. When the
|
|
279
|
+
// last stylesheet finishes loading just after the content swap, WebKit can
|
|
280
|
+
// still scroll to the target in the next rendering update, after our restore,
|
|
281
|
+
// so hold that position for a few frames.
|
|
282
|
+
export function applyFragmentTarget(
|
|
283
|
+
window: Window,
|
|
284
|
+
restoredTop?: number,
|
|
285
|
+
): void {
|
|
267
286
|
const request = ++fragmentTargetRequest;
|
|
268
287
|
const { href } = window.location;
|
|
288
|
+
let waited = false;
|
|
269
289
|
const apply = (): void => {
|
|
270
290
|
if (request !== fragmentTargetRequest || window.location.href !== href) {
|
|
271
291
|
return;
|
|
272
292
|
}
|
|
273
293
|
const pending = Array.from(pendingStylesheets.values());
|
|
274
294
|
if (pending.length > 0) {
|
|
295
|
+
waited = true;
|
|
275
296
|
void Promise.all(pending).then(apply);
|
|
276
297
|
return;
|
|
277
298
|
}
|
|
278
|
-
replaceFragmentTarget(window);
|
|
299
|
+
replaceFragmentTarget(window, waited ? restoredTop : undefined);
|
|
279
300
|
};
|
|
280
301
|
apply();
|
|
281
302
|
}
|
|
282
303
|
|
|
283
|
-
function replaceFragmentTarget(window: Window): void {
|
|
304
|
+
function replaceFragmentTarget(window: Window, forcedTop?: number): void {
|
|
284
305
|
const { document, history, location } = window;
|
|
285
306
|
if (
|
|
307
|
+
forcedTop === undefined &&
|
|
286
308
|
document.querySelector(':target') === getHashTarget(document, location.hash)
|
|
287
309
|
) {
|
|
288
310
|
return;
|
|
289
311
|
}
|
|
290
312
|
const { href } = location;
|
|
291
313
|
const state = history.state;
|
|
292
|
-
const { scrollX
|
|
314
|
+
const { scrollX } = window;
|
|
315
|
+
const scrollY = forcedTop ?? window.scrollY;
|
|
293
316
|
applyingFragment = true;
|
|
294
317
|
try {
|
|
295
318
|
globals.replaceLocation(
|
|
@@ -301,6 +324,41 @@ function replaceFragmentTarget(window: Window): void {
|
|
|
301
324
|
}
|
|
302
325
|
history.replaceState(state, '', href);
|
|
303
326
|
window.scrollTo({ left: scrollX, top: scrollY });
|
|
327
|
+
if (forcedTop !== undefined) {
|
|
328
|
+
holdScroll(window, scrollX, scrollY);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Scroll back to the given position on each of the next few frames if the
|
|
333
|
+
// browser moved the page, until the visitor scrolls or the URL changes
|
|
334
|
+
function holdScroll(window: Window, left: number, top: number): void {
|
|
335
|
+
const { href } = window.location;
|
|
336
|
+
let held = true;
|
|
337
|
+
const release = (): void => {
|
|
338
|
+
held = false;
|
|
339
|
+
for (const type of HOLD_SCROLL_RELEASE_EVENTS) {
|
|
340
|
+
window.removeEventListener(type, release);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
for (const type of HOLD_SCROLL_RELEASE_EVENTS) {
|
|
344
|
+
window.addEventListener(type, release, { passive: true });
|
|
345
|
+
}
|
|
346
|
+
let frames = 0;
|
|
347
|
+
const tick = (): void => {
|
|
348
|
+
if (!held || window.location.href !== href) {
|
|
349
|
+
release();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (Math.abs(window.scrollY - top) > 1) {
|
|
353
|
+
window.scrollTo({ left, top });
|
|
354
|
+
}
|
|
355
|
+
if (++frames < HOLD_SCROLL_FRAMES) {
|
|
356
|
+
window.requestAnimationFrame(tick);
|
|
357
|
+
} else {
|
|
358
|
+
release();
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
window.requestAnimationFrame(tick);
|
|
304
362
|
}
|
|
305
363
|
|
|
306
364
|
export async function navigateToUrl(
|
|
@@ -416,7 +474,10 @@ export async function navigateToUrl(
|
|
|
416
474
|
if (!pushHistory) {
|
|
417
475
|
// Restore the entry's :target before updateHead adds stylesheets, so it
|
|
418
476
|
// applies right away unless an earlier stylesheet is still loading
|
|
419
|
-
applyFragmentTarget(
|
|
477
|
+
applyFragmentTarget(
|
|
478
|
+
window,
|
|
479
|
+
typeof scrollTarget === 'number' ? scrollTarget : undefined,
|
|
480
|
+
);
|
|
420
481
|
}
|
|
421
482
|
updateHead(document, newDoc);
|
|
422
483
|
mountAppearancePickerForPage(window);
|
package/src/toc/_index.scss
CHANGED
|
@@ -149,10 +149,15 @@ body.code .toc ol a {
|
|
|
149
149
|
width: var(--toc-alert-icon-size);
|
|
150
150
|
height: var(--toc-alert-icon-size);
|
|
151
151
|
margin-right: 4px;
|
|
152
|
-
vertical-align:
|
|
152
|
+
vertical-align: middle;
|
|
153
153
|
content: '';
|
|
154
154
|
|
|
155
155
|
@include contained-mask(var(--toc-alert-icon));
|
|
156
|
+
|
|
157
|
+
// Center the icon on the cap height, which scales with each level's font
|
|
158
|
+
@supports (width: 1cap) {
|
|
159
|
+
vertical-align: calc(0.5cap - var(--toc-alert-icon-size) / 2);
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
.toc ol li.label {
|