Add zoomable images

This commit is contained in:
Bojan Mojsilovic 2023-09-13 12:19:52 +02:00
parent 32f41a9b20
commit ffe205c658
9 changed files with 134 additions and 31 deletions

10
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "primal-web-app",
"version": "0.77.3",
"version": "0.77.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "primal-web-app",
"version": "0.77.3",
"version": "0.77.45",
"license": "MIT",
"dependencies": {
"@cookbook/solid-intl": "^0.1.2",
@ -17,6 +17,7 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "^2.4.0",
"dompurify": "^3.0.0",
"medium-zoom": "^1.0.8",
"nostr-tools": "^1.4.1",
"sass": "^1.58.0",
"solid-js": "^1.6.6"
@ -1589,6 +1590,11 @@
"remove-accents": "0.4.2"
}
},
"node_modules/medium-zoom": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/medium-zoom/-/medium-zoom-1.0.8.tgz",
"integrity": "sha512-CjFVuFq/IfrdqesAXfg+hzlDKu6A2n80ZIq0Kl9kWjoHh9j1N9Uvk5X0/MmN0hOfm5F9YBswlClhcwnmtwz7gA=="
},
"node_modules/merge-anything": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.4.tgz",

View File

@ -29,6 +29,7 @@
"@thisbeyond/solid-select": "^0.13.0",
"@types/dompurify": "^2.4.0",
"dompurify": "^3.0.0",
"medium-zoom": "^1.0.8",
"nostr-tools": "^1.4.1",
"sass": "^1.58.0",
"solid-js": "^1.6.6"

View File

@ -3,6 +3,7 @@ import defaultAvatar from '../../assets/icons/default_avatar.svg';
import { useMediaContext } from '../../contexts/MediaContext';
import { hookForDev } from '../../lib/devTools';
import { MediaSize, PrimalUser } from '../../types/primal';
import NoteImage from '../NoteImage/NoteImage';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './Avatar.module.scss';
@ -14,6 +15,7 @@ const Avatar: Component<{
highlightBorder?: boolean,
id?: string,
showCheck?: boolean,
zoomable?: boolean,
}> = (props) => {
const media = useMediaContext();
@ -119,7 +121,11 @@ const Avatar: Component<{
}
>
<div class={`${styles.missingBack} ${notCachedFlag()}`}>
<img src={imageSrc()} alt="avatar" onerror={imgError}/>
<Show when={props.zoomable} fallback={
<img src={imageSrc()} alt="avatar" onerror={imgError}/>
}>
<NoteImage src={imageSrc()} onError={imgError} />
</Show>
</div>
</Show>
<Show when={props.user && props.showCheck}>

View File

@ -0,0 +1,8 @@
.noteImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
z-index: 22;
cursor: pointer;
}

View File

@ -0,0 +1,58 @@
import { Component, JSX, onCleanup, onMount } from "solid-js";
import styles from "./NoteImage.module.scss";
import mediumZoom from "medium-zoom";
import type { Zoom } from 'medium-zoom';
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey } from "nostr-tools";
const NoteImage: Component<{
src?: string,
isDev?: boolean,
onError?: JSX.EventHandlerUnion<HTMLImageElement, Event>,
}> = (props) => {
const imgId = generatePrivateKey();
const imgRef = () => {
return document.getElementById(imgId)
};
let zoomRef: Zoom | undefined;
const klass = () => `${styles.noteImage} ${props.isDev ? 'redBorder' : ''}`;
const doZoom = (e: MouseEvent) => {
if (!e.target || (e.target as HTMLImageElement).id !== imgId) {
return;
}
zoomRef?.open();
};
const getZoom = () => {
const iRef = imgRef();
if (zoomRef || !iRef) {
return zoomRef;
}
zoomRef = mediumZoom(iRef, {
background: "var(--background-site)",
});
zoomRef.attach(iRef);
}
onMount(() => {
getZoom();
document.addEventListener('click', doZoom)
});
onCleanup(() => {
const iRef = imgRef();
iRef && zoomRef && zoomRef.detach(iRef);
document.removeEventListener('click', doZoom)
});
return <img id={imgId} src={props.src} class={klass()} onerror={props.onError} />;
}
export default NoteImage;

View File

@ -1,6 +1,6 @@
import { A } from '@solidjs/router';
import { hexToNpub } from '../../lib/keys';
import { linkPreviews, parseNote1, parseNote3 } from '../../lib/notes';
import { linkPreviews, parseNote1 } from '../../lib/notes';
import { truncateNpub, userName } from '../../stores/profile';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import {
@ -287,7 +287,6 @@ const ParsedNote: Component<{
setDisplayedContent(() => newContent);
});
return (
<div id={props.id} innerHTML={displayedContent()}>
</div>

View File

@ -396,6 +396,18 @@ body {
overflow: hidden;
}
.medium-zoom-image {
cursor: pointer !important;
}
.medium-zoom-overlay {
cursor: pointer !important;
z-index: var(--z-index-header);
}
.medium-zoom-image-opened {
z-index: var(--z-index-floater);
}
// Scrollbars
/* width */

View File

@ -2,6 +2,7 @@
import { Relay } from "nostr-tools";
import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import NoteImage from "../components/NoteImage/NoteImage";
import { Kind } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalNote, SendNoteResult } from "../types/primal";
@ -58,6 +59,23 @@ export const nostrNestsRegex = /nostrnests\.com\/[a-zA-Z0-9]+/i;
export const wavlakeRegex = /(?:player\.)?wavlake\.com\/(track\/[.a-zA-Z0-9-]+|album\/[.a-zA-Z0-9-]+|[.a-zA-Z0-9-]+)/i;
// export const odyseeRegex = /odysee\.com\/([a-zA-Z0-9]+)/;
export const youtubeRegex = /(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:live\/|shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]{1,256}\.[a-zA-Z0-9\u00F0-\u02AF()]{1,8}\b([-a-zA-Z0-9\u00F0-\u02AF()@:%_\+.~#?&//=]*)/g;
export const isImage = (url: string) => ['.jpg', '.jpeg', '.webp', '.png', '.gif', '.format=png'].some(x => url.includes(x));
export const isMp4Video = (url: string) => ['.mp4', '.mov'].some(x => url.includes(x));
export const isOggVideo = (url: string) => ['.ogg'].some(x => url.includes(x));
export const isWebmVideo = (url: string) => ['.webm'].some(x => url.includes(x));
export const isYouTube = (url: string) => youtubeRegex.test(url);
export const isSpotify = (url: string) => spotifyRegex.test(url);
export const isTwitch = (url: string) => twitchRegex.test(url);
export const isMixCloud = (url: string) => mixCloudRegex.test(url);
export const isSoundCloud = (url: string) => soundCloudRegex.test(url);
export const isAppleMusic = (url: string) => appleMusicRegex.test(url);
export const isNostrNests = (url: string) => nostrNestsRegex.test(url);
export const isWavelake = (url: string) => wavlakeRegex.test(url);
export const urlify = (
text: string,
@ -66,43 +84,38 @@ export const urlify = (
skipEmbed = false,
skipLinkPreview = false,
) => {
const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9\u00F0-\u02AF@:%._\+~#=]{1,256}\.[a-zA-Z0-9\u00F0-\u02AF()]{1,8}\b([-a-zA-Z0-9\u00F0-\u02AF()@:%_\+.~#?&//=]*)/g;
return text.replace(urlRegex, (url) => {
return text.replace(urlRegex, (url: string) => {
if (!skipEmbed) {
const isImage = url.includes('.jpg')|| url.includes('.jpeg')|| url.includes('.webp') || url.includes('.png') || url.includes('.gif') || url.includes('format=png');
if (isImage) {
if (isImage(url)) {
const dev = localStorage.getItem('devMode') === 'true';
let imgUrl = getMediaUrl && getMediaUrl(url);
if (!imgUrl) {
return `<img src="${getMediaUrlDefault(url)}" class="postImage${dev ? ' redBorder' : ''}"/>`;
// @ts-ignore
return (<div><NoteImage src={getMediaUrlDefault(url)} isDev={dev} /></div>).outerHTML;
// return `<img src="${getMediaUrlDefault(url)}" class="postImage${dev ? ' redBorder' : ''}"/>`;
}
return `<img src="${imgUrl}" class="postImage"/>`;
// @ts-ignore
return (<div><NoteImage src={imgUrl} isDev={dev} /></div>).outerHTML;
// return `<img src="${imgUrl}" class="postImage"/>`;
}
const isMp4Video = url.includes('.mp4') || url.includes('.mov');
if (isMp4Video) {
if (isMp4Video(url)) {
return `<video class="w-max" controls><source src="${url}" type="video/mp4"></video>`;
}
const isOggVideo = url.includes('.ogg');
if (isOggVideo) {
if (isOggVideo(url)) {
return `<video class="w-max" controls><source src="${url}" type="video/ogg"></video>`;
}
const isWebmVideo = url.includes('.webm');
if (isWebmVideo) {
if (isWebmVideo(url)) {
return `<video class="w-max" controls><source src="${url}" type="video/webm"></video>`;
}
if (youtubeRegex.test(url)) {
if (isYouTube(url)) {
const youtubeId = youtubeRegex.test(url) && RegExp.$1;
return `<iframe
@ -116,20 +129,20 @@ export const urlify = (
></iframe>`;
}
if (spotifyRegex.test(url)) {
if (isSpotify(url)) {
const convertedUrl = url.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
return `<iframe style="borderRadius: 12" src="${convertedUrl}" width="100%" height="352" frameBorder="0" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>`;
}
if (twitchRegex.test(url)) {
if (isTwitch(url)) {
const channel = url.split("/").slice(-1);
const args = `?channel=${channel}&parent=${window.location.hostname}&muted=true`;
return `<iframe src="https://player.twitch.tv/${args}" className="w-max" allowFullScreen></iframe>`;
}
if (mixCloudRegex.test(url)) {
if (isMixCloud(url)) {
const feedPath = (mixCloudRegex.test(url) && RegExp.$1) + "%2F" + (mixCloudRegex.test(url) && RegExp.$2);
// const lightTheme = useLogin().preferences.theme === "light";
@ -145,7 +158,7 @@ export const urlify = (
></iframe>`;
}
if (soundCloudRegex.test(url)) {
if (isSoundCloud(url)) {
return `<iframe
width="100%"
height="166"
@ -154,7 +167,7 @@ export const urlify = (
src="https://w.soundcloud.com/player/?url=${url}"></iframe>`;
}
if (appleMusicRegex.test(url)) {
if (isAppleMusic(url)) {
const convertedUrl = url.replace("music.apple.com", "embed.music.apple.com");
const isSongLink = /\?i=\d+$/.test(convertedUrl);
@ -169,7 +182,7 @@ export const urlify = (
`;
}
if (nostrNestsRegex.test(url)) {
if (isNostrNests(url)) {
return `
<iframe
src="${url}"
@ -181,7 +194,7 @@ export const urlify = (
></iframe>`;
}
if (wavlakeRegex.test(url)) {
if (isWavelake(url)) {
const convertedUrl = url.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
return `

View File

@ -479,11 +479,11 @@ const Profile: Component = () => {
<div class={styles.userImage}>
<div class={styles.avatar}>
<div class={styles.desktopAvatar}>
<Avatar user={profile?.userProfile} size="xxl" />
<Avatar user={profile?.userProfile} size="xxl" zoomable={true} />
</div>
<div class={styles.phoneAvatar}>
<Avatar user={profile?.userProfile} size="lg" />
<Avatar user={profile?.userProfile} size="lg" zoomable={true} />
</div>
</div>
</div>