379 lines
10 KiB
TypeScript
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;
|