Files
cinny/src/app/utils/dom.ts
T
jared cee0c591e2 fix(pwa): N105 — notification clicks work after the tab is closed
OS notifications were shown via page-level `new Notification()` whose onclick
only works while the originating tab is alive — clicking a notification after
closing the tab did nothing.

- New `showOsNotification()` (utils/dom) prefers `registration.showNotification()`
  so the notification is service-worker-owned and persists; falls back to
  `new Notification()` (with the previous onclick) when no SW is available, so
  worst case is unchanged behaviour.
- sw.ts gains a `notificationclick` handler: focuses an existing app window and
  forwards the target path, or opens the app if none is open.
- ClientNonUIFeatures forwards the SW `notificationClick` message to react-router
  `navigate()` (works for both hash and browser router configs), and uses a
  per-room `tag` to coalesce notifications (replacing the old notifRef.close()
  dedup a SW notification can't hold).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:09:57 -04:00

299 lines
9.1 KiB
TypeScript

export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
const targets = evt.composedPath() as Element[];
if (targets.length > 0) {
return targets.find((target) => target.matches?.(selector));
}
// composedPath() is empty when the event is no longer dispatching (e.g. inside a
// portal-within-portal in React 19). Walk up the DOM from evt.target instead.
let el = evt.target instanceof Element ? evt.target : null;
while (el) {
if (el.matches(selector)) return el;
el = el.parentElement;
}
return undefined;
};
export const editableActiveElement = (): boolean =>
!!document.activeElement &&
(document.activeElement.nodeName.toLowerCase() === 'input' ||
document.activeElement.nodeName.toLowerCase() === 'textarea' ||
document.activeElement.getAttribute('contenteditable') === 'true' ||
document.activeElement.getAttribute('role') === 'input' ||
document.activeElement.getAttribute('role') === 'textarea');
export const isIntersectingScrollView = (
scrollElement: HTMLElement,
childElement: HTMLElement,
): boolean => {
const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
const scrollBottom = scrollTop + scrollElement.offsetHeight;
const childTop = childElement.offsetTop;
const childBottom = childTop + childElement.clientHeight;
if (childTop >= scrollTop && childTop < scrollBottom) return true;
if (childBottom > scrollTop && childBottom <= scrollBottom) return true;
if (childTop < scrollTop && childBottom > scrollBottom) return true;
return false;
};
export const isInScrollView = (scrollElement: HTMLElement, childElement: HTMLElement): boolean => {
const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
const scrollBottom = scrollTop + scrollElement.offsetHeight;
return (
childElement.offsetTop >= scrollTop &&
childElement.offsetTop + childElement.offsetHeight <= scrollBottom
);
};
export const canFitInScrollView = (
scrollElement: HTMLElement,
childElement: HTMLElement,
): boolean => childElement.offsetHeight < scrollElement.offsetHeight;
export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
export const getFilesFromFileList = (fileList: FileList): File[] => {
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const file: File | undefined = fileList[i];
if (file instanceof File) files.push(file);
}
return files;
};
export const selectFile = <M extends boolean | undefined = undefined>(
accept: string,
multiple?: M,
): Promise<FilesOrFile<M> | undefined> =>
new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
if (accept) input.accept = accept;
if (multiple) input.multiple = true;
const changeHandler = () => {
const fileList = input.files;
if (!fileList) {
resolve(undefined);
} else {
const files: File[] = getFilesFromFileList(fileList);
resolve((multiple ? files : files[0]) as FilesOrFile<M>);
}
input.removeEventListener('change', changeHandler);
};
input.addEventListener('change', changeHandler);
input.click();
});
export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => {
const fileList = dataTransfer.files;
const files: File[] = getFilesFromFileList(fileList);
if (files.length === 0) return undefined;
return files;
};
export const renameFile = (file: File, name: string): File =>
new File([file], name, { type: file.type });
export const getImageUrlBlob = async (url: string) => {
const res = await fetch(url);
const blob = await res.blob();
return blob;
};
export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
export const loadImageElement = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = document.createElement('img');
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = url;
});
export const loadVideoElement = (url: string): Promise<HTMLVideoElement> =>
new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.playsInline = true;
video.muted = true;
video.onloadeddata = () => {
resolve(video);
video.pause();
};
video.onerror = (e) => {
reject(e);
};
video.src = url;
video.load();
video.play();
});
export const getThumbnailDimensions = (width: number, height: number): [number, number] => {
const MAX_WIDTH = 400;
const MAX_HEIGHT = 300;
let targetWidth = width;
let targetHeight = height;
if (targetHeight > MAX_HEIGHT) {
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
targetHeight = MAX_HEIGHT;
}
if (targetWidth > MAX_WIDTH) {
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
targetWidth = MAX_WIDTH;
}
return [targetWidth, targetHeight];
};
export const getThumbnail = (
img: HTMLImageElement | SVGImageElement | HTMLVideoElement,
width: number,
height: number,
thumbnailMimeType?: string,
): Promise<Blob | undefined> =>
new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
resolve(undefined);
return;
}
context.drawImage(img, 0, 0, width, height);
canvas.toBlob((thumbnail) => {
resolve(thumbnail ?? undefined);
}, thumbnailMimeType ?? 'image/jpeg');
});
export type ScrollInfo = {
offsetTop: number;
top: number;
height: number;
viewHeight: number;
scrollable: boolean;
};
export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
offsetTop: Math.round(target.offsetTop),
top: Math.round(target.scrollTop),
height: Math.round(target.scrollHeight),
viewHeight: Math.round(target.offsetHeight),
scrollable: target.scrollHeight > target.offsetHeight,
});
export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
scrollEl.scrollTo({
top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
behavior,
});
};
export const copyToClipboard = (text: string) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
const host = document.body;
const copyInput = document.createElement('input');
copyInput.style.position = 'fixed';
copyInput.style.opacity = '0';
copyInput.value = text;
host.append(copyInput);
copyInput.select();
copyInput.setSelectionRange(0, 99999);
document.execCommand('Copy');
copyInput.remove();
}
};
export const setFavicon = (url: string): void => {
const favicon = document.querySelector('#favicon');
if (!favicon) return;
favicon.setAttribute('href', url);
};
export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
try {
return decodeURIComponent(encodedURIComponent);
} catch {
return encodedURIComponent;
}
};
export const syntaxErrorPosition = (error: SyntaxError): number | undefined => {
const match = error.message.match(/position\s(\d+)\s/);
if (!match) return undefined;
const posStr = match[1];
const position = parseInt(posStr, 10);
if (Number.isNaN(position)) return undefined;
return position;
};
export const notificationPermission = (permission: NotificationPermission) => {
if ('Notification' in window) {
return window.Notification.permission === permission;
}
try {
// https://stackoverflow.com/questions/29774836/failed-to-construct-notification-illegal-constructor
// https://issues.chromium.org/issues/40415865
// eslint-disable-next-line no-new
new Notification('');
} catch {
return false;
}
return true;
};
/**
* Show an OS notification.
*
* Prefers a service-worker-owned notification (`registration.showNotification`)
* so the notification persists and its click is handled by the SW
* `notificationclick` listener even after the originating tab is closed — the
* data.path is forwarded to the SW. Falls back to a page-level `Notification`
* (with the provided `onClick`) when no service worker is available, preserving
* the previous behaviour.
*/
export const showOsNotification = async (
title: string,
options: NotificationOptions & { data?: { path?: string } },
onClick?: () => void,
): Promise<void> => {
try {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
if (registration && typeof registration.showNotification === 'function') {
await registration.showNotification(title, options);
return;
}
}
} catch {
// fall through to the page-level Notification below
}
const noti = new Notification(title, options);
if (onClick) {
noti.onclick = () => {
onClick();
noti.close();
};
}
};
export const getMouseEventCords = (event: MouseEvent) => ({
x: event.clientX,
y: event.clientY,
width: 0,
height: 0,
});