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 = `` } _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)