@takazudo/zudo-doc 6.1.1 → 6.2.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/CHANGELOG.md +19 -0
- package/README.md +2 -2
- package/dist/code-syntax/mermaid-init-script.d.ts +2 -2
- package/dist/code-syntax/mermaid-init-script.js +1 -1
- package/dist/compiled.css +8 -0
- package/dist/sidebar-toggle-island/index.js +5 -0
- package/dist/sidebar-tree-island/index.js +106 -90
- package/dist/sidebar-tree-island/sidebar-scroll-preserve.d.ts +10 -8
- package/dist/sidebar-tree-island/sidebar-scroll-preserve.js +424 -24
- package/dist/sidebar-utils/index.d.ts +8 -0
- package/dist/sidebar-utils/index.js +51 -6
- package/dist/theme-toggle/index.js +76 -27
- package/dist/wind.json +2 -0
- package/eject/sidebar-toggle-island/index.tsx +5 -0
- package/eject/sidebar-tree-island/index.tsx +16 -2
- package/eject/sidebar-tree-island/sidebar-scroll-preserve.ts +482 -35
- package/eject/theme-toggle/index.tsx +79 -35
- package/package.json +9 -9
- package/tsconfig.base.json +1 -2
|
@@ -7,79 +7,410 @@ interface SidebarScrollPreserveOptions {
|
|
|
7
7
|
document: Document;
|
|
8
8
|
requestAnimationFrame: (callback: FrameRequestCallback) => number;
|
|
9
9
|
cancelAnimationFrame: (handle: number) => void;
|
|
10
|
+
ResizeObserver?: typeof ResizeObserver;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
interface ScrollSlot {
|
|
14
|
+
scrollTop: number;
|
|
15
|
+
anchorId?: string;
|
|
16
|
+
anchorOffset?: number;
|
|
17
|
+
contextId?: string;
|
|
18
|
+
scopeId: string;
|
|
19
|
+
viewKey: string;
|
|
20
|
+
contentHeight: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ViewportKind = "desktop" | "mobile";
|
|
24
|
+
type SlotMap = Partial<Record<ViewportKind, ScrollSlot>>;
|
|
25
|
+
|
|
26
|
+
interface Controller {
|
|
27
|
+
cleanup: () => void;
|
|
28
|
+
notify: (root: Element) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const installedControllers = new WeakMap<Document, Controller>();
|
|
32
|
+
const HISTORY_KEY = "zdSidebarScroll";
|
|
33
|
+
const PENDING_KEY = "zd-sidebar-scroll-pending";
|
|
34
|
+
const VIEWPORTS: ViewportKind[] = ["desktop", "mobile"];
|
|
35
|
+
|
|
36
|
+
/** Directory URLs redirect between slash forms. The snapshot must survive that. */
|
|
37
|
+
function canonicalPath(pathname: string): string {
|
|
38
|
+
return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
39
|
+
}
|
|
13
40
|
|
|
14
41
|
/**
|
|
15
42
|
* Install the scroll-preservation state machine on a document.
|
|
16
43
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
44
|
+
* The desktop aside is replaced when sidebar island props change: preparation
|
|
45
|
+
* drops `data-zfb-transition-persist`, so element identity is not enough for
|
|
46
|
+
* the same logical tree. A saved offset is applied only after the destination
|
|
47
|
+
* can hold it. Chromium clamps a write that happens while `scrollHeight` is
|
|
48
|
+
* still too small, and that clamped 0 would stick.
|
|
20
49
|
*/
|
|
21
50
|
export function installSidebarScrollPreserve({
|
|
22
51
|
document,
|
|
23
52
|
requestAnimationFrame,
|
|
24
53
|
cancelAnimationFrame,
|
|
54
|
+
ResizeObserver: ResizeObserverImpl,
|
|
25
55
|
}: SidebarScrollPreserveOptions): () => void {
|
|
26
|
-
|
|
56
|
+
const view = document.defaultView;
|
|
57
|
+
let epoch = 0;
|
|
58
|
+
let shortRetries = 0;
|
|
59
|
+
let traverse = false;
|
|
60
|
+
let awaiting = false;
|
|
27
61
|
let restoreFrame: number | undefined;
|
|
62
|
+
let didInitial = false;
|
|
63
|
+
let writing = false;
|
|
64
|
+
let observer: { disconnect: () => void } | undefined;
|
|
65
|
+
const observed = new Set<ViewportKind>();
|
|
66
|
+
const skipped = new Set<ViewportKind>();
|
|
67
|
+
const applied = new Set<ViewportKind>();
|
|
68
|
+
let elements: Partial<Record<ViewportKind, HTMLElement>> = {};
|
|
69
|
+
let memory: SlotMap = {};
|
|
70
|
+
let pending: SlotMap | undefined;
|
|
71
|
+
let held: SlotMap = {};
|
|
28
72
|
|
|
29
|
-
const
|
|
73
|
+
const cancelFrame = () => {
|
|
30
74
|
if (restoreFrame === undefined) return;
|
|
31
75
|
cancelAnimationFrame(restoreFrame);
|
|
32
76
|
restoreFrame = undefined;
|
|
33
77
|
};
|
|
78
|
+
const disconnectObserver = () => {
|
|
79
|
+
observer?.disconnect();
|
|
80
|
+
observer = undefined;
|
|
81
|
+
};
|
|
34
82
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
83
|
+
const capture = (): SlotMap => {
|
|
84
|
+
const next: SlotMap = { ...memory };
|
|
85
|
+
for (const kind of VIEWPORTS) {
|
|
86
|
+
const element = findViewport(document, kind);
|
|
87
|
+
if (!element) {
|
|
88
|
+
delete next[kind];
|
|
89
|
+
delete elements[kind];
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// A closed drawer stays in the DOM. Its zero must not replace the open offset.
|
|
93
|
+
if (!measurable(element)) continue;
|
|
94
|
+
if (isEmptyTree(element) && element.scrollTop === 0 && (next[kind]?.scrollTop ?? 0) > 0) continue;
|
|
95
|
+
next[kind] = readSlot(element);
|
|
96
|
+
elements[kind] = element;
|
|
97
|
+
}
|
|
98
|
+
memory = next;
|
|
99
|
+
return next;
|
|
39
100
|
};
|
|
40
101
|
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
43
|
-
const
|
|
44
|
-
|
|
102
|
+
const writeHistory = (slots: SlotMap) => {
|
|
103
|
+
if (!view || traverse) return;
|
|
104
|
+
const state = view.history.state;
|
|
105
|
+
if (!state || typeof state !== "object") return;
|
|
106
|
+
if (JSON.stringify((state as { zdSidebarScroll?: unknown }).zdSidebarScroll) === JSON.stringify(slots)) return;
|
|
107
|
+
try { view.history.replaceState({ ...state, [HISTORY_KEY]: slots }, ""); } catch { /* Optional persistence. */ }
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const schedule = () => {
|
|
111
|
+
if (restoreFrame !== undefined) return;
|
|
45
112
|
restoreFrame = requestAnimationFrame(() => {
|
|
46
113
|
restoreFrame = undefined;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
114
|
+
apply();
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const arm = (kind: ViewportKind, element: HTMLElement): boolean => {
|
|
119
|
+
const Observer = ResizeObserverImpl ?? view?.ResizeObserver;
|
|
120
|
+
if (!Observer || observed.has(kind)) return false;
|
|
121
|
+
observed.add(kind);
|
|
122
|
+
disconnectObserver();
|
|
123
|
+
const epochAt = epoch;
|
|
124
|
+
try {
|
|
125
|
+
const next = new Observer(() => {
|
|
126
|
+
disconnectObserver();
|
|
127
|
+
if (epochAt !== epoch) return;
|
|
128
|
+
apply();
|
|
129
|
+
});
|
|
130
|
+
next.observe(element);
|
|
131
|
+
observer = next;
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const finishIfSettled = (waiting: boolean) => {
|
|
139
|
+
if (waiting) return;
|
|
140
|
+
pending = undefined;
|
|
141
|
+
awaiting = false;
|
|
142
|
+
writeHistory(memory);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const applyHeld = () => {
|
|
146
|
+
let restored = false;
|
|
147
|
+
for (const kind of VIEWPORTS) {
|
|
148
|
+
const slot = held[kind];
|
|
149
|
+
if (!slot) continue;
|
|
150
|
+
const element = findViewport(document, kind);
|
|
151
|
+
if (!element || !measurable(element)) continue;
|
|
152
|
+
const outcome = restoreViewport(kind, element, slot);
|
|
153
|
+
if (outcome === "wait") {
|
|
154
|
+
// Stay held until a later notify sees a tree tall enough to keep the offset.
|
|
155
|
+
arm(kind, element);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
memory[kind] = readSlot(element);
|
|
159
|
+
delete held[kind];
|
|
160
|
+
restored = true;
|
|
161
|
+
}
|
|
162
|
+
if (restored && !awaiting) writeHistory(memory);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const apply = () => {
|
|
166
|
+
if (!pending) {
|
|
167
|
+
if (Object.keys(held).length) applyHeld();
|
|
168
|
+
if (!didInitial) {
|
|
169
|
+
didInitial = true;
|
|
170
|
+
for (const kind of VIEWPORTS) {
|
|
171
|
+
const element = findViewport(document, kind);
|
|
172
|
+
if (element && measurable(element)) revealCurrent(element, writeScroll);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let waiting = false;
|
|
178
|
+
for (const kind of VIEWPORTS) {
|
|
179
|
+
const slot = pending[kind];
|
|
180
|
+
if (!slot || skipped.has(kind) || applied.has(kind)) continue;
|
|
181
|
+
const element = findViewport(document, kind);
|
|
182
|
+
if (!element || !measurable(element)) {
|
|
183
|
+
// A closed drawer stays in the DOM. Park the snapshot for the next
|
|
184
|
+
// open, and keep it in memory so settling this cycle cannot delete it
|
|
185
|
+
// from the history entry. An absent viewport is not a saved zero.
|
|
186
|
+
if (element) {
|
|
187
|
+
held[kind] = slot;
|
|
188
|
+
memory[kind] = slot;
|
|
189
|
+
}
|
|
190
|
+
applied.add(kind);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const outcome = restoreViewport(kind, element, slot);
|
|
194
|
+
if (outcome === "wait") {
|
|
195
|
+
// A short tree must not accept a clamped 0. Retry a few frames, then reveal.
|
|
196
|
+
if (!isEmptyTree(element)) shortRetries += 1;
|
|
197
|
+
arm(kind, element);
|
|
198
|
+
waiting = true;
|
|
199
|
+
if (!isEmptyTree(element) && shortRetries < 8) schedule();
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
memory[kind] = readSlot(element);
|
|
203
|
+
applied.add(kind);
|
|
204
|
+
}
|
|
205
|
+
finishIfSettled(waiting);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const restoreViewport = (kind: ViewportKind, element: HTMLElement, slot: ScrollSlot): "wait" | "done" => {
|
|
209
|
+
const context = readContext(element);
|
|
210
|
+
if (slot.contextId) {
|
|
211
|
+
if (!context) return observed.has(kind) ? "done" : "wait";
|
|
212
|
+
if (context !== slot.contextId) {
|
|
213
|
+
revealCurrent(element, writeScroll);
|
|
214
|
+
return "done";
|
|
215
|
+
}
|
|
216
|
+
} else if (elements[kind] !== element) {
|
|
217
|
+
return "done";
|
|
218
|
+
}
|
|
219
|
+
const canHold = slot.scrollTop === 0 || element.scrollHeight - element.clientHeight + 2 >= slot.scrollTop;
|
|
220
|
+
if (!canHold) {
|
|
221
|
+
// A settled, non-empty tree that is simply shorter than the saved offset
|
|
222
|
+
// cannot grow into that offset. Reveal the current row instead of waiting.
|
|
223
|
+
if (!isEmptyTree(element) && shortRetries >= 8) {
|
|
224
|
+
revealCurrent(element, writeScroll);
|
|
225
|
+
return "done";
|
|
226
|
+
}
|
|
227
|
+
return "wait";
|
|
228
|
+
}
|
|
229
|
+
const compatible = readViewKey(element) === slot.viewKey
|
|
230
|
+
&& readScope(element) === slot.scopeId
|
|
231
|
+
&& Math.abs(element.scrollHeight - slot.contentHeight) <= 2;
|
|
232
|
+
if (compatible) {
|
|
233
|
+
writeScroll(element, slot.scrollTop);
|
|
234
|
+
return "done";
|
|
235
|
+
}
|
|
236
|
+
if (slot.anchorId) {
|
|
237
|
+
const row = findAnchor(element, slot.anchorId);
|
|
238
|
+
if (row) {
|
|
239
|
+
const delta = row.getBoundingClientRect().top - element.getBoundingClientRect().top - (slot.anchorOffset ?? 0);
|
|
240
|
+
const next = element.scrollTop + delta;
|
|
241
|
+
if (next > element.scrollHeight - element.clientHeight + 2) return "wait";
|
|
242
|
+
writeScroll(element, next);
|
|
243
|
+
return "done";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
revealCurrent(element, writeScroll);
|
|
247
|
+
return "done";
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const writeScroll = (element: HTMLElement, next: number) => {
|
|
251
|
+
if (Math.abs(element.scrollTop - next) <= 2) return;
|
|
252
|
+
writing = true;
|
|
253
|
+
element.scrollTop = next;
|
|
254
|
+
writing = false;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const begin = (slots: SlotMap) => {
|
|
258
|
+
epoch += 1;
|
|
259
|
+
cancelFrame();
|
|
260
|
+
disconnectObserver();
|
|
261
|
+
observed.clear();
|
|
262
|
+
skipped.clear();
|
|
263
|
+
applied.clear();
|
|
264
|
+
pending = slots;
|
|
265
|
+
held = {};
|
|
266
|
+
awaiting = true;
|
|
267
|
+
didInitial = true;
|
|
268
|
+
shortRetries = 0;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onBefore = () => {
|
|
272
|
+
if (traverse) {
|
|
273
|
+
traverse = false;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const slots = capture();
|
|
277
|
+
begin(slots);
|
|
278
|
+
awaiting = false;
|
|
279
|
+
writeHistory(slots);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const onAfter = () => {
|
|
283
|
+
try { view?.sessionStorage.removeItem(PENDING_KEY); } catch { /* Optional persistence. */ }
|
|
284
|
+
if (!pending) return;
|
|
285
|
+
awaiting = true;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const onPopState = () => {
|
|
289
|
+
traverse = true;
|
|
290
|
+
begin(readHistory(view) ?? {});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const onClick = (event: Event) => {
|
|
294
|
+
const mouse = event as MouseEvent;
|
|
295
|
+
if (mouse.button !== 0 || mouse.metaKey || mouse.ctrlKey || mouse.shiftKey || mouse.altKey) return;
|
|
296
|
+
const target = mouse.target;
|
|
297
|
+
if (!(target instanceof Element) || !view) return;
|
|
298
|
+
const link = target.closest("a[href]");
|
|
299
|
+
if (!(link instanceof HTMLAnchorElement) || link.target === "_blank") return;
|
|
300
|
+
if (!link.closest("#desktop-sidebar, [data-zd-mobile-sidebar]")) return;
|
|
301
|
+
let url: URL;
|
|
302
|
+
try { url = new URL(link.href, view.location.href); } catch { return; }
|
|
303
|
+
if (url.origin !== view.location.origin) return;
|
|
304
|
+
if (url.pathname === view.location.pathname && url.search === view.location.search) return;
|
|
305
|
+
try {
|
|
306
|
+
view.sessionStorage.setItem(PENDING_KEY, JSON.stringify({ to: canonicalPath(url.pathname), slots: capture() }));
|
|
307
|
+
} catch { /* Optional persistence. */ }
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const onUserScroll = (event: Event) => {
|
|
311
|
+
if (writing) return;
|
|
312
|
+
const kind = viewportKind(event.target);
|
|
313
|
+
if (!kind || skipped.has(kind)) return;
|
|
314
|
+
if (!awaiting && !held[kind]) return;
|
|
315
|
+
if (event.type === "keydown") {
|
|
316
|
+
const key = (event as KeyboardEvent).key;
|
|
317
|
+
if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(key)) return;
|
|
318
|
+
}
|
|
319
|
+
skipped.add(kind);
|
|
320
|
+
applied.add(kind);
|
|
321
|
+
delete held[kind];
|
|
322
|
+
cancelFrame();
|
|
323
|
+
schedule();
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const onScroll = (event: Event) => {
|
|
327
|
+
if (writing || traverse || awaiting) return;
|
|
328
|
+
if (!viewportKind(event.target)) return;
|
|
329
|
+
capture();
|
|
330
|
+
scheduleHistory();
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
let historyFrame: number | undefined;
|
|
334
|
+
const scheduleHistory = () => {
|
|
335
|
+
if (historyFrame !== undefined) return;
|
|
336
|
+
historyFrame = requestAnimationFrame(() => {
|
|
337
|
+
historyFrame = undefined;
|
|
338
|
+
writeHistory(memory);
|
|
51
339
|
});
|
|
52
340
|
};
|
|
53
341
|
|
|
342
|
+
const notify = (root: Element) => {
|
|
343
|
+
if (root.ownerDocument !== document) return;
|
|
344
|
+
if (awaiting) schedule();
|
|
345
|
+
else if (Object.keys(held).length || !didInitial) schedule();
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const consumePendingStorage = () => {
|
|
349
|
+
if (!view) return;
|
|
350
|
+
try {
|
|
351
|
+
const raw = view.sessionStorage.getItem(PENDING_KEY);
|
|
352
|
+
if (!raw) return;
|
|
353
|
+
view.sessionStorage.removeItem(PENDING_KEY);
|
|
354
|
+
const parsed: unknown = JSON.parse(raw);
|
|
355
|
+
if (!isRecord(parsed) || typeof parsed.to !== "string") return;
|
|
356
|
+
if (canonicalPath(parsed.to) !== canonicalPath(view.location.pathname)) return;
|
|
357
|
+
const slots = sanitizeSlots(parsed.slots);
|
|
358
|
+
if (!slots) return;
|
|
359
|
+
begin(slots);
|
|
360
|
+
} catch { /* Malformed storage is ignored. */ }
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
consumePendingStorage();
|
|
364
|
+
if (!pending) {
|
|
365
|
+
const stored = readHistory(view);
|
|
366
|
+
if (stored) begin(stored);
|
|
367
|
+
}
|
|
368
|
+
|
|
54
369
|
document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
|
|
55
370
|
document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
|
|
371
|
+
document.addEventListener("click", onClick, true);
|
|
372
|
+
document.addEventListener("pointerdown", onUserScroll, true);
|
|
373
|
+
document.addEventListener("wheel", onUserScroll, true);
|
|
374
|
+
document.addEventListener("touchmove", onUserScroll, true);
|
|
375
|
+
document.addEventListener("keydown", onUserScroll, true);
|
|
376
|
+
document.addEventListener("scroll", onScroll, true);
|
|
377
|
+
view?.addEventListener("popstate", onPopState);
|
|
56
378
|
|
|
57
|
-
|
|
379
|
+
const cleanup = () => {
|
|
58
380
|
document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
|
|
59
381
|
document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
|
|
60
|
-
|
|
61
|
-
|
|
382
|
+
document.removeEventListener("click", onClick, true);
|
|
383
|
+
document.removeEventListener("pointerdown", onUserScroll, true);
|
|
384
|
+
document.removeEventListener("wheel", onUserScroll, true);
|
|
385
|
+
document.removeEventListener("touchmove", onUserScroll, true);
|
|
386
|
+
document.removeEventListener("keydown", onUserScroll, true);
|
|
387
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
388
|
+
view?.removeEventListener("popstate", onPopState);
|
|
389
|
+
cancelFrame();
|
|
390
|
+
if (historyFrame !== undefined) cancelAnimationFrame(historyFrame);
|
|
391
|
+
disconnectObserver();
|
|
392
|
+
pending = undefined;
|
|
393
|
+
awaiting = false;
|
|
394
|
+
if (installedControllers.get(document)?.cleanup === cleanup) installedControllers.delete(document);
|
|
62
395
|
};
|
|
396
|
+
installedControllers.set(document, { cleanup, notify });
|
|
397
|
+
return cleanup;
|
|
63
398
|
}
|
|
64
399
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
* Repeated calls from component renders or duplicate boot paths are no-ops.
|
|
68
|
-
*/
|
|
69
|
-
export function ensureSidebarScrollPreserve(
|
|
70
|
-
options?: SidebarScrollPreserveOptions,
|
|
71
|
-
): void {
|
|
400
|
+
/** Install exactly one module-lifetime controller for a browser document. */
|
|
401
|
+
export function ensureSidebarScrollPreserve(options?: SidebarScrollPreserveOptions): void {
|
|
72
402
|
const resolved = options ?? resolveBrowserOptions();
|
|
73
403
|
if (!resolved || installedControllers.has(resolved.document)) return;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
404
|
+
installSidebarScrollPreserve(resolved);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The sidebar island calls this after the destination tree has committed. */
|
|
408
|
+
export function notifySidebarScrollReady(root: Element): void {
|
|
409
|
+
installedControllers.get(root.ownerDocument)?.notify(root);
|
|
78
410
|
}
|
|
79
411
|
|
|
80
412
|
function resolveBrowserOptions(): SidebarScrollPreserveOptions | undefined {
|
|
81
|
-
if (typeof document === "undefined" || typeof window === "undefined")
|
|
82
|
-
return undefined;
|
|
413
|
+
if (typeof document === "undefined" || typeof window === "undefined") return undefined;
|
|
83
414
|
return {
|
|
84
415
|
document,
|
|
85
416
|
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
|
@@ -89,6 +420,122 @@ function resolveBrowserOptions(): SidebarScrollPreserveOptions | undefined {
|
|
|
89
420
|
|
|
90
421
|
/** Test/HMR teardown for a controller installed through the singleton wrapper. */
|
|
91
422
|
export function disposeSidebarScrollPreserve(document: Document): void {
|
|
92
|
-
installedControllers.get(document)?.();
|
|
423
|
+
installedControllers.get(document)?.cleanup();
|
|
93
424
|
installedControllers.delete(document);
|
|
94
425
|
}
|
|
426
|
+
|
|
427
|
+
function findViewport(document: Document, kind: ViewportKind): HTMLElement | null {
|
|
428
|
+
if (kind === "desktop") return document.querySelector<HTMLElement>("#desktop-sidebar");
|
|
429
|
+
return document.querySelector<HTMLElement>("aside[data-zd-mobile-sidebar] .flex-1.overflow-y-auto");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function measurable(element: HTMLElement): boolean {
|
|
433
|
+
if (element.clientHeight <= 0 || element.closest("[inert]")) return false;
|
|
434
|
+
const style = element.ownerDocument.defaultView?.getComputedStyle(element);
|
|
435
|
+
return !style || (style.display !== "none" && style.visibility !== "hidden");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isEmptyTree(element: HTMLElement): boolean {
|
|
439
|
+
return element.querySelector("a[href], [data-sidebar-focus-scope]") === null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readContext(element: HTMLElement): string | undefined {
|
|
443
|
+
const value = element.querySelector("nav")?.getAttribute("data-sidebar-context");
|
|
444
|
+
return value || undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function readScope(element: HTMLElement): string {
|
|
448
|
+
return element.querySelector("nav")?.getAttribute("data-sidebar-scope") ?? "";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function readViewKey(element: HTMLElement): string {
|
|
452
|
+
const parts: string[] = [];
|
|
453
|
+
for (const node of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
|
|
454
|
+
const id = node.dataset.sidebarFocusScope || node.getAttribute("href");
|
|
455
|
+
if (id) parts.push(id);
|
|
456
|
+
}
|
|
457
|
+
let hash = 2166136261;
|
|
458
|
+
const serialized = parts.join("\n");
|
|
459
|
+
for (let i = 0; i < serialized.length; i++) hash = Math.imul(hash ^ serialized.charCodeAt(i), 16777619);
|
|
460
|
+
return String(hash >>> 0);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function readSlot(element: HTMLElement): ScrollSlot {
|
|
464
|
+
const anchor = captureAnchor(element);
|
|
465
|
+
return {
|
|
466
|
+
scrollTop: element.scrollTop,
|
|
467
|
+
...anchor,
|
|
468
|
+
contextId: readContext(element),
|
|
469
|
+
scopeId: readScope(element),
|
|
470
|
+
viewKey: readViewKey(element),
|
|
471
|
+
contentHeight: element.scrollHeight,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function captureAnchor(element: HTMLElement): { anchorId?: string; anchorOffset?: number } {
|
|
476
|
+
const bounds = element.getBoundingClientRect();
|
|
477
|
+
for (const row of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
|
|
478
|
+
const rect = row.getBoundingClientRect();
|
|
479
|
+
if (rect.height === 0 && rect.top === 0 && rect.bottom === 0) continue;
|
|
480
|
+
if (rect.bottom > bounds.top && rect.top < bounds.bottom) {
|
|
481
|
+
const anchorId = row.dataset.sidebarFocusScope || row.getAttribute("href") || undefined;
|
|
482
|
+
return anchorId ? { anchorId, anchorOffset: rect.top - bounds.top } : {};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return {};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function findAnchor(element: HTMLElement, id: string): HTMLElement | null {
|
|
489
|
+
for (const node of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
|
|
490
|
+
if (node.dataset.sidebarFocusScope === id || node.getAttribute("href") === id) return node;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function revealCurrent(element: HTMLElement, write: (element: HTMLElement, next: number) => void): void {
|
|
496
|
+
const current = element.querySelector<HTMLElement>('[aria-current="page"]');
|
|
497
|
+
if (!current) return;
|
|
498
|
+
const bounds = element.getBoundingClientRect();
|
|
499
|
+
const row = current.getBoundingClientRect();
|
|
500
|
+
if (row.height === 0 && row.top === 0 && row.bottom === 0) return;
|
|
501
|
+
if (row.top < bounds.top) write(element, element.scrollTop + row.top - bounds.top - 8);
|
|
502
|
+
else if (row.bottom > bounds.bottom) write(element, element.scrollTop + row.bottom - bounds.bottom + 8);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function viewportKind(target: EventTarget | null): ViewportKind | undefined {
|
|
506
|
+
if (!(target instanceof Element)) return undefined;
|
|
507
|
+
if (target.id === "desktop-sidebar" || target.closest("#desktop-sidebar")) return "desktop";
|
|
508
|
+
if (target.closest("[data-zd-mobile-sidebar]")) return "mobile";
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
513
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function sanitizeSlots(value: unknown): SlotMap | undefined {
|
|
517
|
+
if (!isRecord(value)) return undefined;
|
|
518
|
+
const slots: SlotMap = {};
|
|
519
|
+
for (const kind of VIEWPORTS) {
|
|
520
|
+
const slot = value[kind];
|
|
521
|
+
if (!isRecord(slot) || typeof slot.scrollTop !== "number" || !Number.isFinite(slot.scrollTop)) continue;
|
|
522
|
+
if (typeof slot.viewKey !== "string" || typeof slot.scopeId !== "string" || typeof slot.contentHeight !== "number") continue;
|
|
523
|
+
const next: ScrollSlot = {
|
|
524
|
+
scrollTop: slot.scrollTop,
|
|
525
|
+
scopeId: slot.scopeId,
|
|
526
|
+
viewKey: slot.viewKey,
|
|
527
|
+
contentHeight: slot.contentHeight,
|
|
528
|
+
};
|
|
529
|
+
if (typeof slot.contextId === "string") next.contextId = slot.contextId;
|
|
530
|
+
if (typeof slot.anchorId === "string") next.anchorId = slot.anchorId;
|
|
531
|
+
if (typeof slot.anchorOffset === "number") next.anchorOffset = slot.anchorOffset;
|
|
532
|
+
slots[kind] = next;
|
|
533
|
+
}
|
|
534
|
+
return slots;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function readHistory(view: Window | null): SlotMap | undefined {
|
|
538
|
+
const state = view?.history.state;
|
|
539
|
+
if (!isRecord(state)) return undefined;
|
|
540
|
+
return sanitizeSlots(state[HISTORY_KEY]);
|
|
541
|
+
}
|