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/page-visibility": "^2.0.17", | ||||||
|     "@solid-primitives/resize-observer": "^2.0.26", |     "@solid-primitives/resize-observer": "^2.0.26", | ||||||
|     "@solidjs/router": "^0.15.2", |     "@solidjs/router": "^0.15.2", | ||||||
|  |     "@solid-primitives/rootless": "^1.4.5", | ||||||
|     "@suid/icons-material": "^0.8.1", |     "@suid/icons-material": "^0.8.1", | ||||||
|     "@suid/material": "^0.18.0", |     "@suid/material": "^0.18.0", | ||||||
|     "blurhash": "^2.0.5", |     "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, |   Match, | ||||||
|   Switch, |   Switch, | ||||||
|   createMemo, |   createMemo, | ||||||
|   createRenderEffect, |  | ||||||
|   createSignal, |   createSignal, | ||||||
|   onCleanup, |  | ||||||
|   untrack, |   untrack, | ||||||
| } from "solid-js"; | } from "solid-js"; | ||||||
| import MediaViewer from "../MediaViewer"; |  | ||||||
| import { render } from "solid-js/web"; |  | ||||||
| import { | import { | ||||||
|   createElementSize, |   createElementSize, | ||||||
|   useWindowSize, |   useWindowSize, | ||||||
|  | @ -24,6 +20,7 @@ import "~material/cards.css"; | ||||||
| import { Preview } from "@suid/icons-material"; | import { Preview } from "@suid/icons-material"; | ||||||
| import { IconButton } from "@suid/material"; | import { IconButton } from "@suid/material"; | ||||||
| import Masonry from "~platform/Masonry"; | import Masonry from "~platform/Masonry"; | ||||||
|  | import { createMediaQuickview } from "~platform/MediaQuickview"; | ||||||
| 
 | 
 | ||||||
| type ElementSize = { width: number; height: number }; | type ElementSize = { width: number; height: number }; | ||||||
| 
 | 
 | ||||||
|  | @ -58,39 +55,23 @@ const MediaAttachmentGrid: Component<{ | ||||||
|   sensitive?: boolean; |   sensitive?: boolean; | ||||||
| }> = (props) => { | }> = (props) => { | ||||||
|   const [rootRef, setRootRef] = createSignal<HTMLElement>(); |   const [rootRef, setRootRef] = createSignal<HTMLElement>(); | ||||||
|   const [viewerIndex, setViewerIndex] = createSignal<number>(); |  | ||||||
|   const viewerOpened = () => typeof viewerIndex() !== "undefined"; |  | ||||||
|   const settings = useStore($settings); |   const settings = useStore($settings); | ||||||
|   const windowSize = useWindowSize(); |   const windowSize = useWindowSize(); | ||||||
|   const [reveal, setReveal] = createSignal([] as number[]); |   const [reveal, setReveal] = createSignal([] as number[]); | ||||||
| 
 | 
 | ||||||
|   createRenderEffect(() => { |   const openMediaQuickview = createMediaQuickview(); | ||||||
|     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 openViewerFor = (index: number) => { |   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 = () => { |   const columnCount = () => { | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue