flotilla/src/lib/html.ts

221 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-02-16 22:45:54 +00:00
import {sleep, randomId} from "@welshman/lib"
2026-07-08 21:53:49 +00:00
import {Capacitor} from "@capacitor/core"
2025-02-04 03:28:29 +00:00
export {preventDefault, stopPropagation} from "svelte/legacy"
2024-09-11 18:33:50 +00:00
2026-07-03 21:43:00 +00:00
// Anchors an @svelte-plugins/datepicker popup with fixed positioning so it
// escapes scroll-container clipping (e.g. inside modals). Call when the picker
// opens; returns a cleanup function that removes the listeners.
export const anchorDatepicker = (wrapper: HTMLElement) => {
const reposition = () => {
const anchor = wrapper.querySelector("label")
const container = wrapper.querySelector(".calendars-container") as HTMLElement | null
if (!anchor || !container) return
const margin = 8
const rect = anchor.getBoundingClientRect()
const height = container.offsetHeight
const width = container.offsetWidth
let top = rect.bottom + 4
// flip above the input when there's not enough room below, else pin to the bottom edge
if (height && top + height > innerHeight - margin) {
top =
rect.top - height - 4 >= margin
? rect.top - height - 4
: Math.max(margin, innerHeight - height - margin)
}
const left = width
? Math.max(margin, Math.min(rect.left, innerWidth - width - margin))
: rect.left
wrapper.style.setProperty("--datepicker-container-position", "fixed")
wrapper.style.setProperty("--datepicker-container-top", `${top}px`)
wrapper.style.setProperty("--datepicker-container-left", `${left}px`)
}
reposition()
addEventListener("scroll", reposition, true)
addEventListener("resize", reposition)
return () => {
removeEventListener("scroll", reposition, true)
removeEventListener("resize", reposition)
}
}
2024-08-08 23:32:46 +00:00
export const copyToClipboard = (text: string) => {
const {activeElement} = document
const input = document.createElement("textarea")
input.innerHTML = text
document.body.appendChild(input)
input.select()
const result = document.execCommand("copy")
document.body.removeChild(input)
;(activeElement as HTMLElement).focus()
return result
}
2024-09-11 18:33:50 +00:00
2024-11-12 22:32:36 +00:00
export type ScrollerOpts = {
2024-10-01 21:55:33 +00:00
onScroll: () => any
element: Element
2024-09-11 18:33:50 +00:00
threshold?: number
2025-02-06 00:26:22 +00:00
reverse?: boolean
2024-09-24 23:47:36 +00:00
delay?: number
2024-09-11 18:33:50 +00:00
}
2024-11-12 22:32:36 +00:00
export type Scroller = {
check: () => Promise<void>
stop: () => void
}
2024-09-11 18:33:50 +00:00
2024-10-01 21:55:33 +00:00
export const createScroller = ({
onScroll,
element,
delay = 1000,
threshold = 2000,
2025-02-06 00:26:22 +00:00
reverse = false,
2024-10-01 21:55:33 +00:00
}: ScrollerOpts) => {
2024-09-11 18:33:50 +00:00
let done = false
const container = element.classList.contains("scroll-container")
? element
: element.closest(".scroll-container")
2024-09-11 18:33:50 +00:00
const check = async () => {
2026-07-15 16:30:17 +00:00
const isHidden = (el: Element) => !(el as HTMLElement).offsetParent || el.clientHeight === 0
2026-05-28 19:17:17 +00:00
if (container && !isHidden(container)) {
2024-10-17 21:26:53 +00:00
// While we have empty space, fill it
const {scrollY, innerHeight} = window
const {scrollHeight, scrollTop, clientHeight} = container
const viewHeight = clientHeight || innerHeight
2024-10-17 21:26:53 +00:00
const offset = Math.abs(scrollTop || scrollY)
const shouldLoad = reverse
? offset < threshold
: offset + viewHeight + threshold > scrollHeight
2024-10-17 21:26:53 +00:00
// Only trigger loading the first time we reach the threshold
if (shouldLoad) {
await onScroll()
}
2024-09-11 18:33:50 +00:00
}
// No need to check all that often
await sleep(delay)
if (!done) {
requestAnimationFrame(check)
}
}
requestAnimationFrame(check)
return {
check,
stop: () => {
done = true
},
}
}
2024-10-24 22:48:45 +00:00
export const isMobile = "ontouchstart" in document.documentElement
2025-02-14 18:53:36 +00:00
2026-07-08 21:53:49 +00:00
export const downloadText = async (filename: string, text: string) => {
// The <a download> blob trick is a no-op in native WebViews (Android in
// particular never triggers a download), so on device we write the file and
// hand it to the native share sheet, letting the user save it to Files,
// Drive, a password manager, etc. Cache is the directory our FileProvider
// (android/app/src/main/res/xml/file_paths.xml) is configured to serve.
if (Capacitor.isNativePlatform()) {
const {Filesystem, Directory, Encoding} = await import("@capacitor/filesystem")
const {Share} = await import("@capacitor/share")
const {uri} = await Filesystem.writeFile({
path: filename,
data: text,
directory: Directory.Cache,
encoding: Encoding.UTF8,
})
await Share.share({title: filename, files: [uri]})
return
}
2025-02-14 18:53:36 +00:00
const blob = new Blob([text], {type: "text/plain"})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
2025-03-20 16:38:57 +00:00
export const isIntersecting = async (element: Element) =>
new Promise(resolve => {
const observer = new IntersectionObserver(xs => {
resolve(xs.some(x => x.isIntersecting))
observer.unobserve(element)
})
observer.observe(element)
})
export const compressFile = async (
file: File | Blob,
options: Record<string, any> = {},
): Promise<File> => {
2026-02-16 19:34:46 +00:00
const {default: Compressor} = await import("compressorjs-next")
2025-09-15 12:40:02 +00:00
2025-09-18 19:17:31 +00:00
return new Promise<File>((resolve, _reject) => {
2025-09-15 12:40:02 +00:00
new Compressor(file, {
2025-09-18 19:17:31 +00:00
maxWidth: 2048,
maxHeight: 2048,
2026-02-16 19:34:46 +00:00
convertTypes: ["image/png"],
...options,
2026-03-12 20:20:26 +00:00
success: result => {
// canvas.toBlob() returns a Blob, not a File. Capacitor's fetch interceptor
// checks instanceof File to handle binary uploads correctly, so we must ensure
// we always have a real File, not just a Blob with name/lastModified tacked on.
const f =
result instanceof File
? result
: new File([result], (result as any).name || (file as any).name || "upload", {
type: result.type,
})
resolve(f)
},
2025-09-15 12:40:02 +00:00
error: e => {
2025-09-18 19:17:31 +00:00
// Non-images break compressor, return the original file
2025-09-15 12:40:02 +00:00
if (e.toString().includes("File or Blob")) {
2025-09-18 19:17:31 +00:00
if (file instanceof Blob) {
file = new File([file], `${randomId()}.${file.type}`, {type: file.type})
}
return resolve(file as File)
2025-09-15 12:40:02 +00:00
}
_reject(e)
},
})
})
}
2026-02-16 16:16:03 +00:00
export const escapeHtml = (html: string) => {
const element = document.createElement("div")
element.innerText = html
return element.innerHTML
}