WIP: MediaQuickview: rewritten full-screen media viewer #50
					 5 changed files with 261 additions and 31 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -53,6 +53,7 @@ | |||
|     "@solid-primitives/page-visibility": "^2.0.17", | ||||
|     "@solid-primitives/resize-observer": "^2.0.26", | ||||
|     "@solidjs/router": "^0.15.2", | ||||
|     "@solid-primitives/rootless": "^1.4.5", | ||||
|     "@suid/icons-material": "^0.8.1", | ||||
|     "@suid/material": "^0.18.0", | ||||
|     "blurhash": "^2.0.5", | ||||
|  |  | |||
							
								
								
									
										69
									
								
								src/platform/MediaQuickview.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/platform/MediaQuickview.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| .MediaQuickview__root { | ||||
|   display: contents; | ||||
| } | ||||
| 
 | ||||
| .MediaQuickview { | ||||
|   border: none; | ||||
|   position: fixed; | ||||
|   width: 100vw; | ||||
|   width: 100dvw; | ||||
|   height: 100vh; | ||||
|   height: 100dvh; | ||||
|   max-width: 100vw; | ||||
|   max-height: 100vh; | ||||
|   contain: content; | ||||
|   padding: 0; | ||||
| 
 | ||||
|   &::backdrop { | ||||
|     background: none; | ||||
|   } | ||||
| 
 | ||||
|   >.Scaffold>.topbar { | ||||
|     position: fixed; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
| 
 | ||||
|     >* { | ||||
|       background-color: var(--tutu-color-surface); | ||||
|       color: var(--tutu-color-on-surface); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   >.Scaffold>.pages { | ||||
|     display: grid; | ||||
|     grid-auto-flow: column; | ||||
|     grid-auto-columns: 100%; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|     overflow: auto hidden; | ||||
|     scroll-snap-type: x mandatory; | ||||
|     scroll-snap-align: center; | ||||
|     scroll-snap-stop: always; | ||||
| 
 | ||||
| 
 | ||||
|     >.page { | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       max-width: 100vw; | ||||
|       max-height: 100vh; | ||||
|       contain: layout style size paint; | ||||
| 
 | ||||
|       >* { | ||||
|         display: block; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
| 
 | ||||
|         object-fit: contain; | ||||
|         object-position: center; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.lightout { | ||||
|     >.Scaffold { | ||||
|       >.topbar { | ||||
|         visibility: hidden; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										179
									
								
								src/platform/MediaQuickview.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/platform/MediaQuickview.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,179 @@ | |||
| import { createCallback, createSubRoot } from "@solid-primitives/rootless"; | ||||
| import { | ||||
|   createRenderEffect, | ||||
|   createSignal, | ||||
|   Index, | ||||
|   Match, | ||||
|   onCleanup, | ||||
|   onMount, | ||||
|   Switch, | ||||
|   type Component, | ||||
| } from "solid-js"; | ||||
| import { render } from "solid-js/web"; | ||||
| import Scaffold from "~material/Scaffold"; | ||||
| import { isPointNotInRect } from "./dom"; | ||||
| import "./MediaQuickview.css"; | ||||
| import AppTopBar from "~material/AppTopBar"; | ||||
| import { IconButton } from "@suid/material"; | ||||
| import { Close } from "@suid/icons-material"; | ||||
| 
 | ||||
| function renderIsolateMediaQuickview( | ||||
|   each: QuickviewMedia[], | ||||
|   index: number, | ||||
|   transitionFrom?: Element, | ||||
| ) { | ||||
|   createSubRoot((disposeAll) => { | ||||
|     let container: HTMLDivElement; | ||||
| 
 | ||||
|     createRenderEffect(() => { | ||||
|       container = document.createElement("div"); | ||||
|       container.setAttribute("role", "presentation"); | ||||
|       container.classList.add("MediaQuickview__root"); | ||||
|       document.querySelector("body")!.appendChild(container); | ||||
| 
 | ||||
|       onCleanup(() => container.remove()); | ||||
| 
 | ||||
|       const dispose = render(() => { | ||||
|         return ( | ||||
|           <MediaQuickview | ||||
|             each={each} | ||||
|             defaultIndex={index} | ||||
|             transitonFrom={transitionFrom} | ||||
|             onClose={disposeAll} | ||||
|           /> | ||||
|         ); | ||||
|       }, container); | ||||
| 
 | ||||
|       onCleanup(dispose); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function createMediaQuickview() { | ||||
|   return createCallback(renderIsolateMediaQuickview); | ||||
| } | ||||
| 
 | ||||
| function ImagePage(props: { src: string; alt?: string }) { | ||||
|   const [scale, setScale] = createSignal(1); | ||||
|   const [offsetX, setOffsetX] = createSignal(0); | ||||
|   const [offsetY, setOffsetY] = createSignal(0); | ||||
| 
 | ||||
|   const onWheel = (event: WheelEvent & { currentTarget: HTMLElement }) => { | ||||
|     // This is a de-facto standard for scaling:
 | ||||
|     // Browsers will simulate ctrl + wheel for two point scaling gesture.
 | ||||
|     if (event.ctrlKey) { | ||||
|       event.preventDefault(); | ||||
|       event.stopPropagation(); | ||||
| 
 | ||||
|       const offset = event.deltaY; | ||||
| 
 | ||||
|       setScale((x) => x + offset / event.currentTarget.clientHeight); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <img | ||||
|       src={props.src} | ||||
|       alt={props.alt} | ||||
|       onWheel={onWheel} | ||||
|       style={{ | ||||
|         transform: `scale(${scale()}) translateX(${offsetX()}) translateY(${offsetY()})`, | ||||
|       }} | ||||
|       onLoad={({ currentTarget }) => { | ||||
|         const { top, left, width, height } = | ||||
|           currentTarget.getBoundingClientRect(); | ||||
|       }} | ||||
|     ></img> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export type QuickviewMedia = { | ||||
|   cat: "image" | "video" | "gifv" | "audio" | "unknown"; | ||||
|   src: string; | ||||
|   alt?: string; | ||||
| }; | ||||
| 
 | ||||
| export type MediaQuickviewProps = { | ||||
|   each: QuickviewMedia[]; | ||||
|   defaultIndex: number; | ||||
|   transitonFrom?: Element; | ||||
| 
 | ||||
|   onClose?(): void; | ||||
| }; | ||||
| 
 | ||||
| const MediaQuickview: Component<MediaQuickviewProps> = (props) => { | ||||
|   let root: HTMLDialogElement; | ||||
|   const [lightOut, setLightOut] = createSignal(false); | ||||
| 
 | ||||
|   function onDialogClick( | ||||
|     event: MouseEvent & { currentTarget: HTMLDialogElement }, | ||||
|   ) { | ||||
|     event.stopPropagation(); | ||||
| 
 | ||||
|     if ( | ||||
|       isPointNotInRect( | ||||
|         event.currentTarget.getBoundingClientRect(), | ||||
|         event.clientX, | ||||
|         event.clientY, | ||||
|       ) | ||||
|     ) { | ||||
|       event.currentTarget.close(); | ||||
|     } else { | ||||
|       setLightOut((x) => !x); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <dialog | ||||
|       ref={(e) => { | ||||
|         root = e; | ||||
|         onMount(() => { | ||||
|           e.showModal(); | ||||
|         }); | ||||
|       }} | ||||
|       class="MediaQuickview" | ||||
|       classList={{ lightout: lightOut() }} | ||||
|       onClose={props.onClose} | ||||
|       onCancel={props.onClose} | ||||
|       onClick={onDialogClick} | ||||
|     > | ||||
|       <Scaffold | ||||
|         topbar={ | ||||
|           <AppTopBar> | ||||
|             <IconButton color="inherit" onClick={(e) => root.close()}> | ||||
|               <Close /> | ||||
|             </IconButton> | ||||
|           </AppTopBar> | ||||
|         } | ||||
|       > | ||||
|         <div | ||||
|           ref={(e) => { | ||||
|             onMount(() => { | ||||
|               e.children.item(props.defaultIndex)!.scrollIntoView({ | ||||
|                 behavior: "instant", | ||||
|                 inline: "center", | ||||
|               }); | ||||
|             }); | ||||
|           }} | ||||
|           class="pages" | ||||
|         > | ||||
|           <Index each={props.each}> | ||||
|             {(item, index) => { | ||||
|               return ( | ||||
|                 <section class="page"> | ||||
|                   <Switch> | ||||
|                     <Match when={item().cat === "image"}> | ||||
|                       <ImagePage src={item().src} alt={item().alt} /> | ||||
|                     </Match> | ||||
|                   </Switch> | ||||
|                 </section> | ||||
|               ); | ||||
|             }} | ||||
|           </Index> | ||||
|         </div> | ||||
|       </Scaffold> | ||||
|     </dialog> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default MediaQuickview; | ||||
|  | @ -5,13 +5,9 @@ import { | |||
|   Match, | ||||
|   Switch, | ||||
|   createMemo, | ||||
|   createRenderEffect, | ||||
|   createSignal, | ||||
|   onCleanup, | ||||
|   untrack, | ||||
| } from "solid-js"; | ||||
| import MediaViewer from "../MediaViewer"; | ||||
| import { render } from "solid-js/web"; | ||||
| import { | ||||
|   createElementSize, | ||||
|   useWindowSize, | ||||
|  | @ -24,6 +20,7 @@ import "~material/cards.css"; | |||
| import { Preview } from "@suid/icons-material"; | ||||
| import { IconButton } from "@suid/material"; | ||||
| import Masonry from "~platform/Masonry"; | ||||
| import { createMediaQuickview } from "~platform/MediaQuickview"; | ||||
| 
 | ||||
| type ElementSize = { width: number; height: number }; | ||||
| 
 | ||||
|  | @ -58,39 +55,23 @@ const MediaAttachmentGrid: Component<{ | |||
|   sensitive?: boolean; | ||||
| }> = (props) => { | ||||
|   const [rootRef, setRootRef] = createSignal<HTMLElement>(); | ||||
|   const [viewerIndex, setViewerIndex] = createSignal<number>(); | ||||
|   const viewerOpened = () => typeof viewerIndex() !== "undefined"; | ||||
|   const settings = useStore($settings); | ||||
|   const windowSize = useWindowSize(); | ||||
|   const [reveal, setReveal] = createSignal([] as number[]); | ||||
| 
 | ||||
|   createRenderEffect(() => { | ||||
|     const vidx = viewerIndex(); | ||||
|     if (typeof vidx === "undefined") return; | ||||
|     const container = document.createElement("div"); | ||||
|     container.setAttribute("role", "presentation"); | ||||
|     document.body.appendChild(container); | ||||
|     const dispose = render(() => { | ||||
|       onCleanup(() => { | ||||
|         document.body.removeChild(container); | ||||
|       }); | ||||
| 
 | ||||
|       return ( | ||||
|         <MediaViewer | ||||
|           show={viewerOpened()} | ||||
|           index={viewerIndex() || 0} | ||||
|           onIndexUpdated={setViewerIndex} | ||||
|           media={props.attachments} | ||||
|           onClose={() => setViewerIndex()} | ||||
|         /> | ||||
|       ); | ||||
|     }, container); | ||||
| 
 | ||||
|     onCleanup(dispose); | ||||
|   }); | ||||
|   const openMediaQuickview = createMediaQuickview(); | ||||
| 
 | ||||
|   const openViewerFor = (index: number) => { | ||||
|     setViewerIndex(index); | ||||
|     openMediaQuickview( | ||||
|       props.attachments.map((item) => { | ||||
|         return { | ||||
|           cat: item.type, | ||||
|           src: item.url as string, | ||||
|           alt: item.description || undefined, | ||||
|         }; | ||||
|       }), | ||||
|       index, | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   const columnCount = () => { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue