Files
apps/calendar/Events/FilePreview.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

299 lines
9.9 KiB
JavaScript

css(`
filepreview- {
position: fixed;
inset: 0;
z-index: 300;
pointer-events: none;
font-family: 'Arial';
}
`)
class FilePreview extends Shadow {
_file = null
_url = null
_panelEl = null
open(file, url) {
this._file = file
this._url = url
this.rerender()
requestAnimationFrame(() => requestAnimationFrame(() => {
if (this._panelEl) {
this._panelEl.style.transition = "transform 0.35s cubic-bezier(0.32, 0.72, 0, 1)"
this._panelEl.style.transform = "translateY(0)"
}
}))
}
close() {
if (!this._panelEl) return
this._panelEl.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)"
this._panelEl.style.transform = "translateY(100%)"
setTimeout(() => {
this._file = null
this._url = null
this._panelEl = null
this.rerender()
}, 300)
}
render() {
if (!this._file) {
this.style.pointerEvents = "none"
return
}
this.style.pointerEvents = "all"
const type = this._file?.type ?? ""
const isImage = type.startsWith("image/")
const isPDF = type === "application/pdf"
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
this._panelEl = VStack(() => {
this._renderHeader(displayName, isImage)
if (isImage) {
this._renderImageViewer()
} else if (isPDF) {
this._renderPDFViewer()
} else {
this._renderFileFallback(displayName)
}
})
.position("fixed")
.inset(0)
.background(isImage ? "#000" : "var(--main)")
.transform("translateY(100%)")
this._addPanelSwipe()
}
_renderHeader(displayName, isImage) {
HStack(() => {
button("⬇")
.fontSize(1.15, em)
.color("var(--headertext)")
.background("transparent")
.border("none")
.outline("none")
.padding(0)
.paddingLeft(1, rem)
.flexShrink(0)
.cursor("pointer")
.onTap(() => window.open(this._url, "_blank"))
p(displayName)
.margin(0)
.flex(1)
.fontSize(0.88, em)
.fontWeight("600")
.color("var(--headertext)")
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
.textAlign("center")
button("Done")
.fontSize(0.88, em)
.fontWeight("600")
.color("var(--quillred)")
.background("transparent")
.border("none")
.outline("none")
.padding(0)
.paddingRight(1, rem)
.flexShrink(0)
.cursor("pointer")
.onTap(() => this.close())
})
.width(100, pct)
.height(52, px)
.gap(0.75, rem)
.alignItems("center")
.justifyContent("center")
.background(util.darkMode() ? "var(--darkaccent)" : "var(--sidebottombars)")
.borderBottom("1px solid var(--divider)")
.boxSizing("border-box")
.flexShrink(0)
}
_renderImageViewer() {
let imgEl = null
const container = VStack(() => {
imgEl = img(this._url, "auto", "auto")
.display("block")
.maxWidth(100, pct)
.maxHeight(100, pct)
.objectFit("contain")
.pointerEvents("none")
})
.flex(1)
.width(100, pct)
.alignItems("center")
.justifyContent("center")
.overflow("hidden")
if (imgEl) this._setupImageGestures(container, imgEl)
}
_setupImageGestures(container, imgEl) {
let scale = 1, tx = 0, ty = 0
let pinchStartDist = null
let panStartX = 0, panStartY = 0
let swipeStartY = null, swipeStartTime = null
let lastTap = 0
container.style.touchAction = "none"
const applyTransform = (animated = false) => {
imgEl.style.transition = animated ? "transform 0.3s ease" : ""
imgEl.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
const resetZoom = () => {
scale = 1; tx = 0; ty = 0
applyTransform(true)
}
container.addEventListener("touchstart", (e) => {
e.stopPropagation()
if (e.touches.length === 2) {
pinchStartDist = Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
)
swipeStartY = null
} else if (e.touches.length === 1) {
panStartX = e.touches[0].clientX - tx
panStartY = e.touches[0].clientY - ty
if (scale <= 1) {
swipeStartY = e.touches[0].clientY
swipeStartTime = Date.now()
}
}
}, { passive: true })
container.addEventListener("touchmove", (e) => {
if (e.touches.length === 2 && pinchStartDist !== null) {
const dist = Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
)
scale = Math.min(Math.max(scale * (dist / pinchStartDist), 1), 6)
pinchStartDist = dist
applyTransform()
} else if (e.touches.length === 1 && scale > 1) {
tx = e.touches[0].clientX - panStartX
ty = e.touches[0].clientY - panStartY
applyTransform()
} else if (e.touches.length === 1 && swipeStartY !== null) {
const dy = e.touches[0].clientY - swipeStartY
if (dy > 5 && this._panelEl) {
this._panelEl.style.transition = ""
this._panelEl.style.transform = `translateY(${Math.max(0, dy)}px)`
}
}
}, { passive: true })
container.addEventListener("touchend", (e) => {
if (e.touches.length < 2) pinchStartDist = null
if (swipeStartY !== null) {
const dy = e.changedTouches[0].clientY - swipeStartY
const vel = dy / Math.max(1, Date.now() - swipeStartTime)
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
this.close()
} else if (this._panelEl) {
this._panelEl.style.transition = "transform 0.3s ease"
this._panelEl.style.transform = "translateY(0)"
}
swipeStartY = null
}
if (scale < 1) resetZoom()
const now = Date.now()
if (e.changedTouches.length === 1 && now - lastTap < 300) {
scale > 1 ? resetZoom() : (() => { scale = 2.5; applyTransform(true) })()
}
lastTap = now
}, { passive: true })
}
_addPanelSwipe() {
let startY = null, startTime = null
this._panelEl.addEventListener("touchstart", (e) => {
if (e.touches.length !== 1) { startY = null; return }
startY = e.touches[0].clientY
startTime = Date.now()
}, { passive: true })
this._panelEl.addEventListener("touchmove", (e) => {
if (startY === null || e.touches.length !== 1) return
const dy = e.touches[0].clientY - startY
if (dy > 0 && this._panelEl) {
this._panelEl.style.transition = ""
this._panelEl.style.transform = `translateY(${dy}px)`
}
}, { passive: true })
this._panelEl.addEventListener("touchend", (e) => {
if (startY === null) return
const dy = e.changedTouches[0].clientY - startY
const vel = dy / Math.max(1, Date.now() - startTime)
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
this.close()
} else if (this._panelEl) {
this._panelEl.style.transition = "transform 0.3s ease"
this._panelEl.style.transform = "translateY(0)"
}
startY = null
}, { passive: true })
}
_renderPDFViewer() {
const wrap = div()
.flex(1)
.width(100, pct)
.overflow("hidden")
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
}
_renderFileFallback(displayName) {
const type = this._file?.type ?? ""
const icon = {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
'application/msword': "📄",
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
'application/vnd.ms-excel': "📊",
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
'application/vnd.ms-powerpoint': "📋",
'text/plain': "📄",
}[type] ?? "📎"
VStack(() => {
p(icon).fontSize(3, em).margin(0)
p(displayName)
.margin(0)
.fontSize(0.9, em)
.color("var(--headertext)")
.textAlign("center")
a(this._url, "Open / Download")
.attr({ download: displayName })
.fontSize(0.9, em)
.color("var(--quillred)")
.fontWeight("600")
.textDecoration("none")
.marginTop(0.5, em)
})
.flex(1)
.alignItems("center")
.justifyContent("center")
.gap(0.75, em)
}
}
register(FilePreview)