|
|
|
|
@ -2,6 +2,7 @@ import { SvelteMap } from 'svelte/reactivity';
|
|
|
|
|
|
|
|
|
|
import { writableActive, type State } from '$reactive';
|
|
|
|
|
import { isBrowser, isIOS } from '$libs/dom';
|
|
|
|
|
import type { ActiveDom } from './active-dom.svelte';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Options reserved for future expansion (scrollbar padding/margin compensation
|
|
|
|
|
@ -9,6 +10,10 @@ import { isBrowser, isIOS } from '$libs/dom';
|
|
|
|
|
*/
|
|
|
|
|
export type BodyScrollLockOption = Record<string, never>;
|
|
|
|
|
|
|
|
|
|
export interface BodyScrollLockRuntimeOptions {
|
|
|
|
|
dom?: Pick<ActiveDom, 'getDocument' | 'getWindow' | 'listen'>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Properties we mutate on `<body>` while locked. Must all be saved so we can
|
|
|
|
|
* restore the exact pre-lock state when the last lock releases.
|
|
|
|
|
@ -37,6 +42,7 @@ type ModuleState = {
|
|
|
|
|
cleanupTimeoutId: number | null;
|
|
|
|
|
cleanupScheduledAt: number | null;
|
|
|
|
|
idCounter: number;
|
|
|
|
|
dom: BodyScrollLockRuntimeOptions['dom'];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let _state: ModuleState | undefined;
|
|
|
|
|
@ -48,10 +54,34 @@ function state(): ModuleState {
|
|
|
|
|
stopTouchMoveListener: null,
|
|
|
|
|
cleanupTimeoutId: null,
|
|
|
|
|
cleanupScheduledAt: null,
|
|
|
|
|
idCounter: 0
|
|
|
|
|
idCounter: 0,
|
|
|
|
|
dom: undefined
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeDocument(): Document {
|
|
|
|
|
return state().dom?.getDocument() ?? document;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function activeWindow(): Window {
|
|
|
|
|
return state().dom?.getWindow() ?? window;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listen(
|
|
|
|
|
target: EventTarget,
|
|
|
|
|
event: string | readonly string[],
|
|
|
|
|
handler: EventListener,
|
|
|
|
|
options?: boolean | AddEventListenerOptions
|
|
|
|
|
): () => void {
|
|
|
|
|
const dom = state().dom;
|
|
|
|
|
if (dom) return dom.listen(target, event, handler, options);
|
|
|
|
|
const events = Array.isArray(event) ? event : [event];
|
|
|
|
|
for (const e of events) target.addEventListener(e, handler, options);
|
|
|
|
|
return () => {
|
|
|
|
|
for (const e of events) target.removeEventListener(e, handler, options);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextId(): string {
|
|
|
|
|
const s = state();
|
|
|
|
|
s.idCounter += 1;
|
|
|
|
|
@ -68,14 +98,14 @@ function isAnyLocked(): boolean {
|
|
|
|
|
function cancelPendingCleanup() {
|
|
|
|
|
const s = state();
|
|
|
|
|
if (s.cleanupTimeoutId === null || !isBrowser) return;
|
|
|
|
|
window.clearTimeout(s.cleanupTimeoutId);
|
|
|
|
|
activeWindow().clearTimeout(s.cleanupTimeoutId);
|
|
|
|
|
s.cleanupTimeoutId = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function captureBodyProperties() {
|
|
|
|
|
const s = state();
|
|
|
|
|
if (!isBrowser || s.savedProperties !== null) return;
|
|
|
|
|
const style = document.body.style;
|
|
|
|
|
const style = activeDocument().body.style;
|
|
|
|
|
const saved: SavedProperties = {};
|
|
|
|
|
for (const prop of MANAGED_PROPERTIES) {
|
|
|
|
|
const value = style.getPropertyValue(prop);
|
|
|
|
|
@ -87,7 +117,7 @@ function captureBodyProperties() {
|
|
|
|
|
function restoreBodyProperties() {
|
|
|
|
|
if (!isBrowser) return;
|
|
|
|
|
const s = state();
|
|
|
|
|
const style = document.body.style;
|
|
|
|
|
const style = activeDocument().body.style;
|
|
|
|
|
const saved = s.savedProperties ?? {};
|
|
|
|
|
for (const prop of MANAGED_PROPERTIES) {
|
|
|
|
|
const original = saved[prop];
|
|
|
|
|
@ -110,16 +140,18 @@ function detachTouchMoveListener() {
|
|
|
|
|
function attachTouchMoveListener() {
|
|
|
|
|
const s = state();
|
|
|
|
|
if (!isBrowser || !isIOS || s.stopTouchMoveListener) return;
|
|
|
|
|
const doc = activeDocument();
|
|
|
|
|
|
|
|
|
|
const listener = (event: TouchEvent) => {
|
|
|
|
|
if (event.target !== document.documentElement) return;
|
|
|
|
|
if (event.target !== doc.documentElement) return;
|
|
|
|
|
if (event.touches.length > 1) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('touchmove', listener, { passive: false });
|
|
|
|
|
s.stopTouchMoveListener = listen(doc, 'touchmove', listener as EventListener, { passive: false });
|
|
|
|
|
const teardown = s.stopTouchMoveListener;
|
|
|
|
|
s.stopTouchMoveListener = () => {
|
|
|
|
|
document.removeEventListener('touchmove', listener);
|
|
|
|
|
teardown();
|
|
|
|
|
state().stopTouchMoveListener = null;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
@ -130,12 +162,14 @@ function applyBodyLock() {
|
|
|
|
|
cancelPendingCleanup();
|
|
|
|
|
captureBodyProperties();
|
|
|
|
|
|
|
|
|
|
const style = document.body.style;
|
|
|
|
|
const htmlStyle = getComputedStyle(document.documentElement);
|
|
|
|
|
const bodyStyle = getComputedStyle(document.body);
|
|
|
|
|
const doc = activeDocument();
|
|
|
|
|
const win = activeWindow();
|
|
|
|
|
const style = doc.body.style;
|
|
|
|
|
const htmlStyle = win.getComputedStyle(doc.documentElement);
|
|
|
|
|
const bodyStyle = win.getComputedStyle(doc.body);
|
|
|
|
|
const hasStableGutter =
|
|
|
|
|
htmlStyle.scrollbarGutter?.includes('stable') || bodyStyle.scrollbarGutter?.includes('stable');
|
|
|
|
|
const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
|
|
|
const verticalScrollbarWidth = win.innerWidth - doc.documentElement.clientWidth;
|
|
|
|
|
const paddingRight = Number.parseInt(bodyStyle.paddingRight || '0', 10);
|
|
|
|
|
|
|
|
|
|
if (verticalScrollbarWidth > 0 && !hasStableGutter) {
|
|
|
|
|
@ -166,7 +200,7 @@ function scheduleCleanupIfNoNewLocks(delay: number | null, callback: () => void)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
s.cleanupTimeoutId = window.setTimeout(cleanupFn, delay ?? 24);
|
|
|
|
|
s.cleanupTimeoutId = activeWindow().setTimeout(cleanupFn, delay ?? 24);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class BodyScrollLock {
|
|
|
|
|
@ -175,8 +209,10 @@ export class BodyScrollLock {
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
initialState?: boolean,
|
|
|
|
|
private readonly restoreScrollDelay: () => number | null = () => null
|
|
|
|
|
private readonly restoreScrollDelay: () => number | null = () => null,
|
|
|
|
|
options: BodyScrollLockRuntimeOptions = {}
|
|
|
|
|
) {
|
|
|
|
|
state().dom = options.dom;
|
|
|
|
|
state().lockMap.set(this.id, initialState ?? false);
|
|
|
|
|
|
|
|
|
|
this.locked = writableActive(
|
|
|
|
|
|