refactor: extract PipImage component from inline PiP markup

Deduplicates mobile PiP image code shared between the rosary page and
StickyImage. Adds fullscreen support to StickyImage and fixes hidden PiP
elements capturing pointer events via pointer-events: none default.
This commit is contained in:
2026-02-10 21:15:50 +01:00
parent 6eaf0bb4f4
commit b3c3f34e50
4 changed files with 141 additions and 175 deletions

View File

@@ -0,0 +1,119 @@
<script>
/**
* @param {ReturnType<import('$lib/js/pip.svelte').createPip>} pip - a createPip() instance
* @param {string} src - image source
* @param {string} [alt] - image alt text
* @param {boolean} [visible] - whether the PiP should be shown
* @param {(e: Event) => void} [onload] - callback when image loads
*/
let { pip, src, alt = '', visible = false, onload, el = $bindable(null) } = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pip-container"
class:visible
class:enlarged={pip.enlarged}
class:fullscreen={pip.fullscreen}
bind:this={el}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
{#if src}
<img {src} {alt} {onload}>
{/if}
{#if pip.showControls}
<button
class="pip-fullscreen-btn"
aria-label="Fullscreen"
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); pip.toggleFullscreen(); }}
>
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8 3 3 3 3 8"/>
<polyline points="16 3 21 3 21 8"/>
<polyline points="8 21 3 21 3 16"/>
<polyline points="16 21 21 21 21 16"/>
</svg>
</button>
{/if}
</div>
<style>
.pip-container {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
pointer-events: none;
}
.pip-container:active {
cursor: grabbing;
}
.pip-container img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.pip-container.enlarged img {
height: 37.5vh;
}
.pip-container.fullscreen {
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.pip-container.fullscreen img {
border-radius: 0;
box-shadow: none;
}
.pip-fullscreen-btn {
all: unset;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: transparent;
filter: drop-shadow(0 0 1px black);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
z-index: 1;
pointer-events: auto;
outline: none;
transition: transform 0.15s ease;
}
.pip-fullscreen-btn:hover,
.pip-fullscreen-btn:active {
transform: translate(-50%, -50%) scale(1.2);
}
.pip-container.fullscreen .pip-fullscreen-btn {
top: auto;
left: auto;
bottom: 10vw;
right: 10vw;
transform: none;
}
.pip-container.fullscreen .pip-fullscreen-btn:hover,
.pip-container.fullscreen .pip-fullscreen-btn:active {
transform: scale(0.85);
}
</style>

View File

@@ -1,6 +1,7 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createPip } from '$lib/js/pip.svelte'; import { createPip } from '$lib/js/pip.svelte';
import PipImage from '$lib/components/PipImage.svelte';
/** /**
* @param {'layout' | 'overlay'} mode * @param {'layout' | 'overlay'} mode
@@ -13,7 +14,7 @@
let contentEl = $state(null); let contentEl = $state(null);
let inView = $state(false); let inView = $state(false);
const pip = createPip(); const pip = createPip({ fullscreenEnabled: true });
function isMobile() { function isMobile() {
return !window.matchMedia('(min-width: 1024px)').matches; return !window.matchMedia('(min-width: 1024px)').matches;
@@ -80,17 +81,10 @@
</script> </script>
<div class="sticky-image-layout" class:overlay={mode === 'overlay'}> <div class="sticky-image-layout" class:overlay={mode === 'overlay'}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="image-wrap-desktop">
<div
class="image-wrap"
class:enlarged={pip.enlarged}
bind:this={pipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
<img {src} {alt}> <img {src} {alt}>
</div> </div>
<PipImage {pip} {src} {alt} visible={inView} bind:el={pipEl} />
<div class="content-scroll" bind:this={contentEl}> <div class="content-scroll" bind:this={contentEl}>
{@render children()} {@render children()}
</div> </div>
@@ -107,32 +101,8 @@
.sticky-image-layout.overlay { .sticky-image-layout.overlay {
display: contents; display: contents;
} }
.image-wrap { .image-wrap-desktop {
position: fixed; display: none;
top: 0;
left: 0;
z-index: 10000;
width: auto;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
}
.image-wrap:active {
cursor: grabbing;
}
.image-wrap img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.image-wrap.enlarged img {
height: 37.5vh;
} }
.content-scroll { .content-scroll {
width: 100%; width: 100%;
@@ -148,29 +118,18 @@
gap: 2rem; gap: 2rem;
width: calc(100% + 25vw + 2rem); width: calc(100% + 25vw + 2rem);
} }
.overlay .image-wrap { .image-wrap-desktop {
display: block;
position: sticky; position: sticky;
top: 4rem; top: 4rem;
left: auto;
transform: none !important;
width: auto;
align-self: start; align-self: start;
order: 1; order: 1;
opacity: 1;
z-index: auto;
cursor: default;
touch-action: auto;
user-select: auto;
transition: none;
} }
.overlay .image-wrap img { .overlay .image-wrap-desktop img {
height: auto; height: auto;
max-height: calc(100vh - 5rem); max-height: calc(100vh - 5rem);
width: auto; width: auto;
max-width: 25vw; max-width: 25vw;
border-radius: 0;
box-shadow: none;
pointer-events: auto;
} }
.sticky-image-layout:not(.overlay) { .sticky-image-layout:not(.overlay) {
flex-direction: row; flex-direction: row;
@@ -180,37 +139,27 @@
.sticky-image-layout:not(.overlay) .content-scroll { .sticky-image-layout:not(.overlay) .content-scroll {
flex: 0 1 700px; flex: 0 1 700px;
} }
.sticky-image-layout:not(.overlay) .image-wrap { .sticky-image-layout:not(.overlay) .image-wrap-desktop {
display: block;
position: sticky; position: sticky;
top: 4rem; top: 4rem;
left: auto;
transform: none !important;
opacity: 1;
flex: 1; flex: 1;
background-color: transparent;
padding: 0;
order: 1; order: 1;
cursor: default;
touch-action: auto;
user-select: auto;
transition: none;
} }
.sticky-image-layout:not(.overlay) .image-wrap img { .sticky-image-layout:not(.overlay) .image-wrap-desktop img {
max-height: calc(100vh - 4rem); max-height: calc(100vh - 4rem);
height: auto; height: auto;
width: 100%; width: 100%;
object-fit: contain; object-fit: contain;
border-radius: 0;
box-shadow: none;
} }
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
.sticky-image-layout:not(.overlay) .image-wrap { .sticky-image-layout:not(.overlay) .image-wrap-desktop {
background-color: var(--nord5); background-color: var(--nord5);
} }
} }
@media (prefers-color-scheme: light) and (min-width: 1024px) { @media (prefers-color-scheme: light) and (min-width: 1024px) {
.sticky-image-layout:not(.overlay) .image-wrap { .sticky-image-layout:not(.overlay) .image-wrap-desktop {
background-color: transparent; background-color: transparent;
} }
} }

View File

@@ -155,12 +155,14 @@ export function createPip(opts: PipOptions = {}) {
showControls = false; showControls = false;
if (fullscreen) { if (fullscreen) {
target.style.opacity = '1'; target.style.opacity = '1';
target.style.pointerEvents = 'auto';
return; return;
} }
const pos = getCornerPos(corner, target); const pos = getCornerPos(corner, target);
dragPos = pos; dragPos = pos;
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`; target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.style.opacity = '1'; target.style.opacity = '1';
target.style.pointerEvents = 'auto';
} }
function hide() { function hide() {
@@ -178,7 +180,10 @@ export function createPip(opts: PipOptions = {}) {
el.style.transition = ''; el.style.transition = '';
} }
} }
if (el) el.style.opacity = '0'; if (el) {
el.style.opacity = '0';
el.style.pointerEvents = 'none';
}
} }
function reposition() { function reposition() {

View File

@@ -2,6 +2,7 @@
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { createLanguageContext } from "$lib/contexts/languageContext.js"; import { createLanguageContext } from "$lib/contexts/languageContext.js";
import { createPip } from "$lib/js/pip.svelte"; import { createPip } from "$lib/js/pip.svelte";
import PipImage from "$lib/components/PipImage.svelte";
import "$lib/css/christ.css"; import "$lib/css/christ.css";
import "$lib/css/action_button.css"; import "$lib/css/action_button.css";
import Kreuzzeichen from "$lib/components/prayers/Kreuzzeichen.svelte"; import Kreuzzeichen from "$lib/components/prayers/Kreuzzeichen.svelte";
@@ -1462,86 +1463,6 @@ h1 {
} }
} }
/* Mobile PiP for mystery images */
.mystery-pip {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
opacity: 0;
touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
}
.mystery-pip:active {
cursor: grabbing;
}
.mystery-pip img {
height: 25vh;
width: auto;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: height 0.25s ease;
}
.mystery-pip.enlarged img {
height: 37.5vh;
}
.mystery-pip.fullscreen {
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
cursor: default;
}
.mystery-pip.fullscreen img {
border-radius: 0;
box-shadow: none;
}
.pip-fullscreen-btn {
all: unset;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: transparent;
filter: drop-shadow(0 0 1px black);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
z-index: 1;
pointer-events: auto;
outline: none;
transition: transform 0.15s ease;
}
.pip-fullscreen-btn:hover,
.pip-fullscreen-btn:active {
transform: translate(-50%, -50%) scale(1.2);
}
.mystery-pip.fullscreen .pip-fullscreen-btn {
top: auto;
left: auto;
bottom: 10vw;
right: 10vw;
transform: none;
}
.mystery-pip.fullscreen .pip-fullscreen-btn:hover,
.mystery-pip.fullscreen .pip-fullscreen-btn:active {
transform: scale(0.85);
}
@media (min-width: 1200px) {
.mystery-pip {
display: none;
}
}
</style> </style>
<svelte:head> <svelte:head>
<title>{labels.pageTitle}</title> <title>{labels.pageTitle}</title>
@@ -1957,35 +1878,7 @@ h1 {
<!-- Mobile PiP for mystery images --> <!-- Mobile PiP for mystery images -->
{#if hasMysteryImages} {#if hasMysteryImages}
<!-- svelte-ignore a11y_no_static_element_interactions --> <PipImage {pip} src={lastPipSrc} visible={!!mysteryPipSrc} onload={() => pip.reposition()} bind:el={rosaryPipEl} />
<div
class="mystery-pip"
class:visible={!!mysteryPipSrc}
class:enlarged={pip.enlarged}
class:fullscreen={pip.fullscreen}
bind:this={rosaryPipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
{#if lastPipSrc}
<img src={lastPipSrc} alt="" onload={() => pip.reposition()}>
{/if}
{#if pip.showControls}
<button
class="pip-fullscreen-btn"
onpointerdown={(e) => e.stopPropagation()}
onclick={(e) => { e.stopPropagation(); pip.toggleFullscreen(); }}
>
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8 3 3 3 3 8"/>
<polyline points="16 3 21 3 21 8"/>
<polyline points="8 21 3 21 3 16"/>
<polyline points="16 21 21 21 21 16"/>
</svg>
</button>
{/if}
</div>
{/if} {/if}
</div> </div>