import type { mastodon } from "masto"; import { For, type Component, type ParentComponent, Switch, Match, createEffect, createSignal, type JSX, onMount, Index, mergeProps, requestCallback, untrack, } from "solid-js"; import { css } from "solid-styled"; import { useHeroSource } from "../platform/anim"; import { Portal } from "solid-js/web"; import { createStore } from "solid-js/store"; import { IconButton, Toolbar } from "@suid/material"; import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material"; type MediaViewerProps = { show: boolean; index: number; media: mastodon.v1.MediaAttachment[]; onIndexUpdated?: (newIndex: number) => void; onClose?: () => void; }; export const MEDIA_VIEWER_HEROSRC = Symbol("mediaViewerHeroSrc"); function within(n: number, target: number, range: number) { return n >= target - range || n <= target + range; } function clamp(input: number, min: number, max: number) { return Math.min(Math.max(input, min), max) } const MediaViewer: ParentComponent = (props) => { let rootRef: HTMLDialogElement; const heroSource = useHeroSource(); const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC]; type State = { ref?: HTMLElement; media: mastodon.v1.MediaAttachment; top: number; left: number; scale: number; osize: [number, number]; // width, height }; const [state, setState] = createStore( props.media.map( (media) => ({ top: 0, left: 0, ref: undefined, media, scale: 1, osize: [0, 9], }) as State, ), ); const [showControls, setShowControls] = createSignal(true); const [dragging, setDragging] = createSignal(false); const hasPrev = () => state.length > 1 && props.index !== 0; const hasNext = () => state.length > 1 && props.index < state.length - 1; css` .media-viewer--root { background: none; border: none; overflow: hidden; margin: 0; padding: 0; outline: none; max-width: 100%; max-height: 100%; height: 100%; width: 100%; &[open] { display: block; } } .media-viewer { display: grid; grid-auto-flow: column; grid-auto-columns: 100%; width: 100%; height: 100%; overflow: auto; background-color: ${showControls() ? "var(--tutu-color-surface)" : "var(--tutu-color-on-surface)"}; transition: background-color 0.2s var(--tutu-anim-curve-std); scroll-behavior: smooth; > .media { height: 100%; } } .media { overflow: hidden; position: relative; > img { position: absolute; object-fit: contain; top: 0; left: 0; transform-origin: center; } } .media-ctrls { width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; cursor: ${dragging() ? "grabbing" : "grab"}; } .left-dock { position: absolute; left: 24px; top: 50%; transform: translateY(-50%) ${showControls() && hasPrev() ? "" : "translateX(-100%) translateX(-24px)"}; display: inline-block; transition: transform 0.2s var(--tutu-anim-curve-std); } .right-dock { position: absolute; right: 24px; top: 50%; transform: translateY(-50%) ${showControls() && hasNext() ? "" : "translateX(100%) translateX(24px)"}; display: inline-block; transition: transform 0.2s var(--tutu-anim-curve-std); } `; createEffect(() => { if (props.show) { rootRef.showModal(); untrack(() => { for (let i = 0; i < state.length; i++) { centre(state[i], i); } }); } else { rootRef.close(); } }); createEffect(() => { const viewer = rootRef.children.item(0)!; const targetPageE = viewer.children.item(props.index + 1); if (!targetPageE) return; targetPageE.scrollIntoView(); }); const minScaleOf = (state: State) => { const { ref, osize: [width, height], } = state; const { width: parentWidth, height: parentHeight } = ref!.parentElement!.getBoundingClientRect(); if (height <= parentHeight && width <= parentWidth) { return 1; } return Math.min(parentHeight / height, parentWidth / width); }; // Position medias to the centre. // This function is only available when the elements are layout. const centre = ({ ref, osize: [width, height] }: State, idx: number) => { const { width: parentWidth, height: parentHeight } = ref!.parentElement!.getBoundingClientRect(); const scale = height <= parentHeight && width <= parentWidth ? 1 : Math.min(parentHeight / height, parentWidth / width); const top = parentHeight / 2 - height / 2; const left = parentWidth / 2 - width / 2; setState(idx, { top, left, scale }); }; const scale = ( center: readonly [number, number], // left, top move: number, idx: number, ) => { const { ref, top: otop, left: oleft, scale: oscale, osize: [owidth, oheight] } = state[idx]; const [cx, cy] = center; const iy = clamp(cy - otop, 0, oheight), ix = clamp(cx - oleft, 0, owidth); // in image coordinate system const scale = move + oscale; const oix = ix / oscale, oiy = iy / oscale; const nix = oix * scale, niy = oiy * scale; // Now we can calculate the center's move const { width: vw, height: vh } = ref!.parentElement!.getBoundingClientRect(); const top = vh / 2 - niy; const left = vw / 2 - nix; setState(idx, { top, left, scale }); }; const movePrev = () => { props.onIndexUpdated?.(Math.max(props.index - 1, 0)); }; const moveNext = () => { props.onIndexUpdated?.(Math.min(props.index + 1, state.length - 1)); }; const ctrlWheel = (event: WheelEvent) => { if (event.ctrlKey && event.deltaY !== 0) { event.preventDefault(); const center = [event.clientX, event.clientY] as const; scale(center, -event.deltaY / event.clientY, props.index); } else { if (event.deltaX !== 0) { event.preventDefault(); if (event.deltaX > 0) { moveNext(); } else { movePrev(); } } } }; let lastMousedown: [number, number, number] | null = null; // time, left, top const ctrlMouseDown = (event: MouseEvent) => { if (event.buttons !== 1) return; event.preventDefault(); lastMousedown = [Date.now(), event.clientX, event.clientY]; setDragging(true); }; const ctrlMouseMove = (event: MouseEvent) => { if (!lastMousedown) return; event.preventDefault(); const { movementX: mleft, movementY: mtop } = event; setState(props.index, (o) => ({ left: o.left + mleft, top: o.top + mtop })); }; const ctrlMouseUp = (event: MouseEvent) => { if (lastMousedown !== null) { event.preventDefault(); const [time, left, top] = lastMousedown; const { clientX: nleft, clientY: ntop } = event; const now = Date.now(); const target = event.target; checkControls: { if ( target instanceof Element && !target.classList.contains("media-ctrls") ) { // It's dispatched from sub controls, exits break checkControls; } if ( now - time < 250 && within(left, nleft, 4) && within(top, ntop, 4) ) { setShowControls((x) => !x); } } lastMousedown = null; setDragging(false); } }; return (
(movePrev(), e.stopPropagation())} >
(moveNext(), e.stopPropagation())} >
{(item, index) => { return (
{JSON.stringify(item().media, undefined, 2)} } > { setState(index, { ref: r }); }} onLoad={(e) => { const { naturalWidth: width, naturalHeight: height } = e.currentTarget; setState(index, { osize: [width, height], }); }} src={item().media.url || undefined} style={{ left: `${item().left}px`, top: `${item().top}px`, transform: `scale(${item().scale})`, }} alt={item().media.description || undefined} >
); }}
); }; export default MediaViewer;