tutu/src/timelines/MediaViewer.tsx
2024-07-14 20:28:44 +08:00

379 lines
10 KiB
TypeScript

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<MediaViewerProps> = (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<State[]>(
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 (
<dialog ref={rootRef!} class="media-viewer--root">
<div class="media-viewer">
<div
class="media-ctrls"
onWheel={ctrlWheel}
onMouseDown={ctrlMouseDown}
onMouseUp={ctrlMouseUp}
onMouseMove={ctrlMouseMove}
>
<Toolbar
variant="dense"
sx={{
backgroundColor: "var(--tutu-color-surface)",
transform: !showControls() ? "translateY(-100%)" : undefined,
transition:
"transform 0.2s var(--tutu-anim-curve-std), box-shadow 0.2s var(--tutu-anim-curve-std)",
boxShadow: showControls() ? "var(--tutu-shadow-e6)" : undefined,
}}
>
<IconButton onClick={props.onClose}>
<Close />
</IconButton>
</Toolbar>
<div class="left-dock">
<IconButton
size="large"
onClick={(e) => (movePrev(), e.stopPropagation())}
>
<ArrowLeft />
</IconButton>
</div>
<div class="right-dock">
<IconButton
size="large"
onClick={(e) => (moveNext(), e.stopPropagation())}
>
<ArrowRight />
</IconButton>
</div>
</div>
<Index each={state}>
{(item, index) => {
return (
<div class="media" data-index={index}>
<Switch
fallback={
<pre>{JSON.stringify(item().media, undefined, 2)}</pre>
}
>
<Match when={item().media.type === "image"}>
<img
ref={(r) => {
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}
></img>
</Match>
</Switch>
</div>
);
}}
</Index>
</div>
</dialog>
);
};
export default MediaViewer;