{displayImages.map((image, i) => (
@@ -120,7 +131,7 @@ export default function ImageGallery({
}
const portal =
- lightboxPortalActive && typeof document !== 'undefined'
+ !tapToLoadGallery && lightboxPortalActive && typeof document !== 'undefined'
? createPortal(
(null)
const [probeFailed, setProbeFailed] = useState(false)
const [embedPainted, setEmbedPainted] = useState(false)
@@ -66,14 +67,10 @@ export default function MediaPlayer({
/** Probe result wins when set (e.g. audio-only mp4); URL hint avoids a blank frame before useEffect runs. */
const effectiveMediaType = mediaType ?? urlEmbedTypeHint
- const showEmbed = mustLoad || display
+ const showEmbed = mustLoad || autoLoadMedia || userClickedLoad
- useEffect(() => {
- if (autoLoadMedia) {
- setDisplay(true)
- } else {
- setDisplay(false)
- }
+ useLayoutEffect(() => {
+ if (!autoLoadMedia) setUserClickedLoad(false)
}, [autoLoadMedia])
useEffect(() => {
@@ -148,13 +145,13 @@ export default function MediaPlayer({
setEmbedPainted(true)
}, [])
- if (!mustLoad && !display) {
+ if (!mustLoad && !showEmbed) {
return (
setDisplay(true)}
+ onActivate={() => setUserClickedLoad(true)}
className={className}
/>
)
diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
index 9664a15a..8ef3a460 100644
--- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -528,6 +528,11 @@ export default function AsciidocArticle({
return images
}, [extractedMedia.images, metadata.image])
+
+ const lightboxSlides = useMemo(
+ () => allImages.map((img) => lightboxSlideFromImeta(img)),
+ [allImages]
+ )
// Create image index map for lightbox
const imageIndexMap = useMemo(() => {
@@ -571,11 +576,13 @@ export default function AsciidocArticle({
// Note: contentLinks removed - WebPreview is disabled for AsciiDoc articles
- // Image gallery state
+ // Image gallery state — portal only while open (see MarkdownArticle lightbox comment).
const [lightboxIndex, setLightboxIndex] = useState(-1)
-
+ const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
+
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index)
+ setLightboxPortalActive(true)
}, [])
// Filter tag media to only show what's not in content
@@ -2122,39 +2129,45 @@ export default function AsciidocArticle({
{/* Image gallery lightbox */}
- {allImages.length > 0 && createPortal(
-
e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- lightboxSlideFromImeta(img))}
- plugins={[Video, Zoom]}
- open={lightboxIndex >= 0}
- close={() => setLightboxIndex(-1)}
- controller={{
- closeOnBackdropClick: false,
- closeOnPullUp: true,
- closeOnPullDown: true
- }}
- render={{
- buttonPrev: allImages.length <= 1 ? () => null : undefined,
- buttonNext: allImages.length <= 1 ? () => null : undefined
- }}
- styles={{
- toolbar: { paddingTop: '2.25rem' }
- }}
- carousel={{
- finite: false
- }}
- />
-
,
- document.body
- )}
+ {allImages.length > 0 &&
+ lightboxPortalActive &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ = 0}
+ close={() => setLightboxIndex(-1)}
+ on={{
+ exited: () => setLightboxPortalActive(false)
+ }}
+ controller={{
+ closeOnBackdropClick: false,
+ closeOnPullUp: true,
+ closeOnPullDown: true
+ }}
+ render={{
+ buttonPrev: allImages.length <= 1 ? () => null : undefined,
+ buttonNext: allImages.length <= 1 ? () => null : undefined
+ }}
+ styles={{
+ toolbar: { paddingTop: '2.25rem' }
+ }}
+ carousel={{
+ finite: false
+ }}
+ />
+
,
+ document.body
+ )}
>
)
}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
index 1f3e8ddc..3eceaab9 100644
--- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -28,7 +28,7 @@ import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { TEmoji, TImetaInfo } from '@/types'
import { emojis, shortcodeToEmoji } from '@tiptap/extension-emoji'
-import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
+import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { lightboxSlideFromImeta } from '@/lib/lightbox-slides'
import Lightbox from 'yet-another-react-lightbox'
@@ -4646,6 +4646,11 @@ export default function MarkdownArticle({
return images
}, [extractedMedia.images, metadata.image])
+
+ const lightboxSlides = useMemo(
+ () => allImages.map((img) => lightboxSlideFromImeta(img)),
+ [allImages]
+ )
// Helper function to extract image filename/hash from URL for comparison
// This helps identify the same image hosted on different domains
@@ -4751,14 +4756,21 @@ export default function MarkdownArticle({
return links
}, [event.content])
- // Image gallery state
+ // Image gallery state — portal mounts only while active so feed re-renders don't run N closed Lightboxes on body.
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
-
+ const [lightboxPortalActive, setLightboxPortalActive] = useState(false)
+
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index)
setLightboxOpen(true)
+ setLightboxPortalActive(true)
}, [])
+
+ useLayoutEffect(() => {
+ setLightboxOpen(false)
+ setLightboxPortalActive(false)
+ }, [lazyMedia])
// Filter tag media to only show what's not in content
const leftoverTagMedia = useMemo(() => {
@@ -5181,43 +5193,47 @@ export default function MarkdownArticle({
)}
- {/* Image gallery lightbox */}
- {allImages.length > 0 && createPortal(
- e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- onMouseDown={(e) => e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
- >
- lightboxSlideFromImeta(img))}
- plugins={[Video, Zoom]}
- open={lightboxOpen}
- close={() => setLightboxOpen(false)}
- on={{
- view: ({ index }) => setLightboxIndex(index)
- }}
- controller={{
- closeOnBackdropClick: false,
- closeOnPullUp: true,
- closeOnPullDown: true
- }}
- render={{
- buttonPrev: allImages.length <= 1 ? () => null : undefined,
- buttonNext: allImages.length <= 1 ? () => null : undefined
- }}
- styles={{
- toolbar: { paddingTop: '2.25rem' }
- }}
- carousel={{
- finite: false
- }}
- />
-
,
- document.body
- )}
+ {/* Image gallery lightbox — mount portal only when open; avoids N× Lightbox reconciling on body when policy/feed re-renders */}
+ {allImages.length > 0 &&
+ lightboxPortalActive &&
+ typeof document !== 'undefined' &&
+ createPortal(
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ setLightboxOpen(false)}
+ on={{
+ view: ({ index }) => setLightboxIndex(index),
+ exited: () => setLightboxPortalActive(false)
+ }}
+ controller={{
+ closeOnBackdropClick: false,
+ closeOnPullUp: true,
+ closeOnPullDown: true
+ }}
+ render={{
+ buttonPrev: allImages.length <= 1 ? () => null : undefined,
+ buttonNext: allImages.length <= 1 ? () => null : undefined
+ }}
+ styles={{
+ toolbar: { paddingTop: '2.25rem' }
+ }}
+ carousel={{
+ finite: false
+ }}
+ />
+
,
+ document.body
+ )}
>
)
}
diff --git a/src/components/YoutubeEmbeddedPlayer/index.tsx b/src/components/YoutubeEmbeddedPlayer/index.tsx
index 471e26ff..da896c37 100644
--- a/src/components/YoutubeEmbeddedPlayer/index.tsx
+++ b/src/components/YoutubeEmbeddedPlayer/index.tsx
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service'
import { YouTubePlayer } from '@/types/youtube'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ExternalLink from '../ExternalLink'
import logger from '@/lib/logger'
@@ -20,23 +20,21 @@ export default function YoutubeEmbeddedPlayer({
const { t } = useTranslation()
const contentPolicy = useContentPolicyOptional()
const autoLoadMedia = contentPolicy?.autoLoadMedia ?? true
- const [display, setDisplay] = useState(autoLoadMedia)
+ const [userClickedLoad, setUserClickedLoad] = useState(false)
const { videoId, isShort } = useMemo(() => parseYoutubeUrl(url), [url])
const [initSuccess, setInitSuccess] = useState(false)
const [error, setError] = useState(false)
const playerRef = useRef