Current section
Files
Jump to
Current section
Files
src/off_topic/internal/subscriptions_ffi.mjs
import { iterate, resolveElement } from "./utils_ffi.mjs";
import { BitArray$BitArray, BitArray$BitArray$data } from "../../gleam.mjs";
export function on(target, eventName, dispatch, root) {
const element = resolveElement(target, root);
if (!element) throw new Error("invalid target");
element.addEventListener(eventName, dispatch, { passive: true });
return () => element.removeEventListener(eventName, dispatch);
}
export function on_page_state(dispatch) {
const getState = () => {
if (document.visibilityState === "hidden") return "hidden";
if (document.hasFocus()) return "active";
return "passive";
};
const handler = (newState) => {
if (newState !== lastState) {
dispatch(newState);
lastState = newState;
}
};
let lastState = getState();
// dispatch initial state if it's not "active", the default.
if (lastState !== "active") {
dispatch(lastState);
}
// Options used for all event listeners.
const abort = new AbortController();
const opts = { capture: true, signal: abort.signal };
// These lifecycle events can all use the same listener to observe state
// changes (they call the `getState()` function to determine the next state).
["pageshow", "focus", "blur", "visibilitychange", "resume"].forEach((type) => {
window.addEventListener(type, () => handler(getState()), opts);
});
// The next two listeners, on the other hand, can determine the next
// state from the event itself.
window.addEventListener(
"freeze",
() => {
// In the freeze event, the next state is always frozen.
handler("frozen");
},
opts,
);
window.addEventListener(
"pagehide",
(event) => {
// If the event's persisted property is `true` the page is about
// to enter the back/forward cache, which is also in the frozen state.
// If the event's persisted property is not `true` the page is
// about to be unloaded.
handler(event.persisted ? "frozen" : "terminated");
},
opts,
);
return () => abort.abort();
}
export function here(dispatch) {
const getOffset = () => -new Date().getTimezoneOffset();
let lastOffset = getOffset();
dispatch(lastOffset);
const handle = setInterval(() => {
const offset = getOffset();
if (offset !== lastOffset) {
lastOffset = offset;
dispatch(offset);
}
}, 60_000);
return () => clearInterval(handle);
}
export const online = enter_exit(
window,
"online",
"offline",
() => navigator.onLine,
);
export const window_hover = enter_exit(
document.documentElement,
"pointerenter",
"pointerleave",
() => document.documentElement.matches(":hover"),
);
export function prevent_unload(callback) {
const handler = (event) => {
event.preventDefault();
callback();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}
export function timer(interval, callback) {
const handle = setInterval(callback, interval);
return () => clearInterval(handle);
}
export function on_idle(timeout_ms, dispatch) {
let idle = false;
let timer = null;
const reset = () => {
if (idle) {
idle = false;
dispatch(false);
}
clearTimeout(timer);
timer = setTimeout(() => {
idle = true;
dispatch(true);
}, timeout_ms);
};
const onVisibilityChange = () => {
if (!document.hidden) reset();
};
const windowEvents = [
"pointermove",
"pointerdown",
"keydown",
"scroll",
"wheel",
"resize",
];
for (const event of windowEvents) {
window.addEventListener(event, reset, { passive: true });
}
document.addEventListener("visibilitychange", onVisibilityChange);
reset();
return () => {
clearTimeout(timer);
for (const event of windowEvents) {
window.removeEventListener(event, reset);
}
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}
export function window_size(dispatch) {
return trigger(null, window, "resize", () =>
dispatch(window.innerWidth, window.innerHeight),
);
}
export function window_scroll(dispatch) {
return trigger(null, window, "scroll", () =>
dispatch(window.scrollX, window.scrollY),
);
}
export function screen_orientation(dispatch) {
return trigger(null, screen.orientation, "change", () =>
dispatch(screen.orientation.type),
);
}
export function language(dispatch) {
return trigger(null, window, "languagechange", () =>
dispatch(navigator.language),
);
}
export function media_query(fallback, pairs, dispatch) {
const matchers = [];
iterate(pairs, ([query, value]) => {
matchers.push([window.matchMedia(query), value]);
});
const current = () => matchers.find(([mq]) => mq.matches)?.[1] ?? fallback;
const handler = () => dispatch(current());
handler();
for (const [mq] of matchers) mq.addEventListener("change", handler);
return () => {
for (const [mq] of matchers) mq.removeEventListener("change", handler);
};
}
export const local_storage = storage(localStorage);
export const session_storage = storage(sessionStorage);
function storage(storage) {
return (key, dispatch) => {
const value = storage.getItem(key);
dispatch(value !== null, value ?? "");
const handler = (event) => {
if (event.storageArea === storage && event.key === key) {
const v = event.newValue;
dispatch(v !== null, v ?? "");
}
};
return on(window, "storage", handler);
};
}
export function element_size(selector, dispatch, root) {
const el = resolveElement(selector, root);
if (!el) return noop;
const { width, height } = el.getBoundingClientRect();
dispatch(width, height);
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
dispatch(width, height);
});
observer.observe(el);
return () => observer.disconnect();
}
export function element_visible(selector, dispatch, root) {
const el = resolveElement(selector, root);
if (!el) return noop;
const rect = el.getBoundingClientRect();
dispatch(
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < window.innerHeight &&
rect.left < window.innerWidth,
);
const observer = new IntersectionObserver((entries) => {
dispatch(entries[0].isIntersecting);
});
observer.observe(el);
return () => observer.disconnect();
}
export function element_scroll(selector, dispatch, root) {
const el = resolveElement(selector, root);
if (!el) return noop;
const handler = () => dispatch(el.scrollLeft, el.scrollTop);
handler();
el.addEventListener("scroll", handler, { passive: true });
return () => el.removeEventListener("scroll", handler);
}
export function title(value) {
document.title = value;
return noop;
}
export function meta(name, content) {
let el = document.head.querySelector(`meta[name="${CSS.escape(name)}"]`);
if (!el) {
el = document.createElement("meta");
el.setAttribute("name", name);
document.head.appendChild(el);
}
el.setAttribute("content", content);
return noop;
}
export function body_class(className) {
const classes = className.split(" ");
document.body.classList.add(...classes);
return () => document.body.classList.remove(...classes);
}
export function aria(name, value, _handler, root) {
const host = root instanceof ShadowRoot ? root.host : root;
const internals = host?.internals;
if (internals) {
const prop =
name === "role"
? "role"
: "aria" +
name
.slice(5)
.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase());
internals[prop] = value;
}
return noop;
}
export function pseudo_state(name, checked, _handler, root) {
const host = root instanceof ShadowRoot ? root.host : root;
const internals = host?.internals;
if (internals) {
if (checked) {
internals.states.add(name);
} else {
internals.states.delete(name);
}
}
return noop;
}
export function form_value(exists, value, _handler, root) {
const host = root instanceof ShadowRoot ? root.host : root;
host?.internals?.setFormValue(exists ? value : undefined);
return noop;
}
function trigger(root, selector, event, trigger) {
const el = resolveElement(selector, root);
if (!el) noop;
trigger();
return on(el, event, trigger);
}
function enter_exit(target, enterEvent, exitEvent, getValue) {
return (dispatch) => {
dispatch(getValue());
const enter = () => dispatch(true);
const leave = () => dispatch(false);
target.addEventListener(enterEvent, enter);
target.addEventListener(exitEvent, leave);
return () => {
target.removeEventListener(enterEvent, enter);
target.removeEventListener(exitEvent, leave);
};
};
}
export function sse(url, on_open, on_message, on_error) {
const source = new EventSource(url);
source.onopen = () => on_open();
source.onmessage = (event) =>
on_message(event.lastEventId ?? "", event.data ?? "");
source.onerror = () => on_error();
return () => source.close();
}
const ws_registry = new Map();
function ws_open(url) {
const existing = ws_registry.get(url);
if (existing) return existing;
const entry = { ws: null, listeners: new Set(), timer: null };
ws_registry.set(url, entry);
ws_connect(url, entry);
return entry;
}
function ws_connect(url, entry) {
const ws = new WebSocket(url);
entry.ws = ws;
ws.binaryType = "arraybuffer";
ws.onopen = () => {
for (const { on_open } of entry.listeners) on_open();
};
ws.onmessage = (event) => {
for (const { on_text, on_binary } of entry.listeners) {
if (typeof event.data === "string") {
on_text(event.data);
} else {
on_binary(BitArray$BitArray(new Uint8Array(event.data)));
}
}
};
ws.onclose = (event) => {
const msg = event.reason || `${event.code}`;
for (const { on_error } of entry.listeners) on_error(msg);
if (entry.listeners.size > 0) {
entry.timer = setTimeout(() => ws_connect(url, entry), 3000);
}
};
}
export function websocket(url, on_open, on_text, on_binary, on_error) {
const entry = ws_open(url);
const listener = { on_open, on_text, on_binary, on_error };
entry.listeners.add(listener);
return () => {
entry.listeners.delete(listener);
if (entry.listeners.size === 0) {
clearTimeout(entry.timer);
entry.ws?.close(1000);
ws_registry.delete(url);
}
};
}
export function websocket_send_text(url, text) {
const entry = ws_registry.get(url);
if (entry?.ws?.readyState === WebSocket.OPEN) entry.ws.send(text);
}
export function websocket_send_binary(url, bytes) {
const entry = ws_registry.get(url);
if (entry?.ws?.readyState === WebSocket.OPEN)
entry.ws.send(BitArray$BitArray$data(bytes));
}
function noop() {}