diff --git a/bun.lockb b/bun.lockb index 247b232..cd4221a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 25814fe..69b71d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/platform/MediaQuickview.css b/src/platform/MediaQuickview.css new file mode 100644 index 0000000..3ad798c --- /dev/null +++ b/src/platform/MediaQuickview.css @@ -0,0 +1,76 @@ +.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: strict; + + cursor: grab; + + >* { + display: block; + + object-fit: contain; + object-position: center; + transform-origin: 0 0; + } + } + } + + &.lightout { + >.Scaffold { + >.topbar { + visibility: hidden; + } + } + } + + &.moving { + >.Scaffold>.pages>.page { + cursor: grabbing; + } + } +} \ No newline at end of file diff --git a/src/platform/MediaQuickview.tsx b/src/platform/MediaQuickview.tsx new file mode 100644 index 0000000..c4b2cde --- /dev/null +++ b/src/platform/MediaQuickview.tsx @@ -0,0 +1,331 @@ +import { createCallback, createSubRoot } from "@solid-primitives/rootless"; +import { + batch, + createMemo, + 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"; +import { createStore, unwrap } from "solid-js/store"; + +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 ( + + ); + }, container); + + onCleanup(dispose); + }); + }); +} + +export function createMediaQuickview() { + return createCallback(renderIsolateMediaQuickview); +} + +type VisualMediaMeta = { + width: number; + height: number; +}; + +function ImagePage(props: { + src: string; + alt?: string; + scale: number; + offsetX: number; + offsetY: number; + + onMetadata?(metadata: VisualMediaMeta): void; +}) { + const [loaded, setLoaded] = createSignal(false); + + return ( + { + const { naturalHeight, naturalWidth } = currentTarget; + + props.onMetadata?.({ + width: naturalWidth, + height: naturalHeight, + }); + + setLoaded(true); + }} + src={props.src} + alt={props.alt} + style={{ + transform: `scale(${props.scale}) translateX(${-props.offsetX}px) translateY(${-props.offsetY}px)`, + opacity: loaded() ? 1 : 0, + }} + > + ); +} + +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 = (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); + } + } + + const [transformations, setTransformations] = createStore( + [] as { + scale: number; + /** + * positive = the left edge move towards left + */ + offsetX: number; + /** + * positive = the top edge move towards top + */ + offsetY: number; + + size?: { + width: number; + height: number; + }; + }[], + ); + + const transformationGetOrSetDefault = (index: number) => { + if (transformations.length <= index) { + setTransformations(index, { + scale: 1, + offsetX: 0, + offsetY: 0, + }); + } + return transformations[index]; + }; + + const onWheel = ( + index: number, + event: WheelEvent & { currentTarget: HTMLElement }, + ) => { + // This is a de-facto standard for scaling: + // Browsers will simulate ctrl + wheel for two point scaling gesture on trackpad. + if (event.ctrlKey) { + event.preventDefault(); + event.stopPropagation(); + + const { clientX, clientY, currentTarget, deltaY } = event; + + const offset = -deltaY; // not reversed wheel: wheel up = scale +, wheel down = scale - + const scaleOffset = offset / currentTarget.clientHeight; + + // Map the screen scale to the image viewport scale + const userOriginX = clientX - currentTarget.clientLeft; + const userOriginY = clientY - currentTarget.clientTop; + setTransformations(index, ({ scale, offsetX, offsetY }) => { + const nscale = scale + scaleOffset; + return { + offsetX: offsetX + (userOriginX * nscale - userOriginX), + offsetY: offsetY + (userOriginY * nscale - userOriginX), + scale: nscale, + }; + }); + } + }; + + const [isMoving, setIsMoving] = createSignal(false); + let movOriginX: number = 0, + movOriginY: number = 0; + + const onMouseDown = (event: MouseEvent) => { + event.preventDefault(); + setIsMoving(true); + movOriginX = event.clientX; + movOriginY = event.clientY; + }; + + const onMouseMove = (index: number, event: MouseEvent) => { + if (!isMoving()) { + return; + } + const dx = movOriginX - event.clientX; + const dy = movOriginY - event.clientY; + + setTransformations(index, ({ offsetX, offsetY }) => { + return { + offsetX: offsetX + dx, + offsetY: offsetY + dy, + }; + }); + + movOriginX = event.clientX; + movOriginY = event.clientY; + }; + + const onMouseUp = (event: MouseEvent) => { + setIsMoving(false); + }; + + const recenter = (index: number) => { + const sz = transformations[index].size; + if (!sz) return; + const { width, height } = sz; + + const xscale = Math.min(window.innerWidth / width, 1); + const yscale = Math.min(window.innerHeight / height, 1); + + const finalScale = Math.min(xscale, yscale); + + const nheight = height * finalScale; + const top = (window.innerHeight - nheight) / 2; + + const nwidth = width * finalScale; + const left = (window.innerWidth - nwidth) / 2; + + setTransformations(index, { + scale: finalScale, + offsetX: -left, + offsetY: -top, + }); + }; + + const onMetadata = (index: number, { width, height }: VisualMediaMeta) => { + setTransformations(index, { + size: { + width, + height, + }, + }); + + recenter(index); + }; + + return ( + { + root = e; + onMount(() => { + e.showModal(); + }); + }} + class="MediaQuickview" + classList={{ lightout: lightOut(), moving: isMoving() }} + onClose={props.onClose} + onCancel={props.onClose} + onClick={onDialogClick} + > + + root.close()} + disableFocusRipple + > + + + + } + > +
{ + onMount(() => { + e.children.item(props.defaultIndex)!.scrollIntoView({ + behavior: "instant", + inline: "center", + }); + }); + }} + class="pages" + > + + {(item, index) => { + return ( +
+ + + onMetadata(index, m)} + src={item().src} + alt={item().alt} + scale={transformationGetOrSetDefault(index).scale} + offsetX={transformationGetOrSetDefault(index).offsetX} + offsetY={transformationGetOrSetDefault(index).offsetY} + /> + + +
+ ); + }} +
+
+
+
+ ); +}; + +export default MediaQuickview; diff --git a/src/timelines/toots/MediaAttachmentGrid.tsx b/src/timelines/toots/MediaAttachmentGrid.tsx index 4b22564..77e7dda 100644 --- a/src/timelines/toots/MediaAttachmentGrid.tsx +++ b/src/timelines/toots/MediaAttachmentGrid.tsx @@ -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(); - const [viewerIndex, setViewerIndex] = createSignal(); - 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 ( - 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 = () => {