extract PiP drag/snap/enlarge logic into shared createPip() utility
All checks were successful
CI / update (push) Successful in 1m32s

Both StickyImage and rosary page now use the same pip.svelte.ts factory
for mobile drag-to-corner, snap, and double-tap enlarge behavior.
This commit is contained in:
2026-02-09 08:48:05 +01:00
parent 6433576b28
commit a4738134fe
3 changed files with 232 additions and 133 deletions

View File

@@ -1,5 +1,6 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { createPip } from '$lib/js/pip.svelte';
/** /**
* @param {'layout' | 'overlay'} mode * @param {'layout' | 'overlay'} mode
@@ -10,17 +11,9 @@
let pipEl = $state(null); let pipEl = $state(null);
let contentEl = $state(null); let contentEl = $state(null);
let corner = $state('bottom-right');
let dragging = $state(false);
let enlarged = $state(false);
let inView = $state(false); let inView = $state(false);
let dragOffset = { x: 0, y: 0 };
let dragPos = $state({ x: 0, y: 0 }); const pip = createPip();
let dragMoved = false;
let lastTapTime = 0;
const MARGIN = 16;
const TAP_THRESHOLD = 10;
const DOUBLE_TAP_MS = 400;
function isMobile() { function isMobile() {
return !window.matchMedia('(min-width: 1024px)').matches; return !window.matchMedia('(min-width: 1024px)').matches;
@@ -31,121 +24,14 @@
return isMobile(); return isMobile();
} }
// Whether the image visibility is controlled by IntersectionObserver
function isObserverControlled() {
return mode === 'overlay';
}
function getCornerPos(c, el) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = el.getBoundingClientRect();
return {
'top-left': { x: MARGIN, y: MARGIN },
'top-right': { x: vw - r.width - MARGIN, y: MARGIN },
'bottom-left': { x: MARGIN, y: vh - r.height - MARGIN },
'bottom-right': { x: vw - r.width - MARGIN, y: vh - r.height - MARGIN },
}[c];
}
function snapToCorner(el, c) {
const pos = getCornerPos(c, el);
corner = c;
dragPos = pos;
el.style.transition = 'transform 0.25s ease';
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
el.addEventListener('transitionend', () => { el.style.transition = ''; }, { once: true });
}
function nearestCorner(x, y, el) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = el.getBoundingClientRect();
const cx = x + r.width / 2;
const cy = y + r.height / 2;
const left = cx < vw / 2;
const top = cy < vh / 2;
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}`;
}
function onPointerDown(e) {
if (!pipEl || !isPipActive()) return;
dragging = true;
dragMoved = false;
const r = pipEl.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
pipEl.setPointerCapture(e.pointerId);
pipEl.style.transition = '';
e.preventDefault();
}
function onPointerMove(e) {
if (!dragging || !pipEl) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
if (!dragMoved) {
const dx = Math.abs(x - dragPos.x);
const dy = Math.abs(y - dragPos.y);
if (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD) dragMoved = true;
}
dragPos = { x, y };
pipEl.style.transform = `translate(${x}px, ${y}px)`;
}
function toggleEnlarged() {
if (!pipEl) return;
const rect = pipEl.getBoundingClientRect();
const vh = window.innerHeight / 100;
const currentH = enlarged ? 37.5 * vh : 25 * vh;
const targetH = enlarged ? 25 * vh : 37.5 * vh;
const ratio = targetH / currentH;
enlarged = !enlarged;
const newW = rect.width * ratio;
const newH = rect.height * ratio;
let newX = rect.left;
let newY = rect.top;
if (corner.includes('right')) newX = rect.right - newW;
if (corner.includes('bottom')) newY = rect.bottom - newH;
dragPos = { x: newX, y: newY };
pipEl.style.transition = 'transform 0.25s ease';
pipEl.style.transform = `translate(${newX}px, ${newY}px)`;
pipEl.addEventListener('transitionend', () => {
pipEl.style.transition = '';
}, { once: true });
}
function onPointerUp(e) {
if (!dragging || !pipEl) return;
dragging = false;
if (!dragMoved) {
const now = Date.now();
if (now - lastTapTime < DOUBLE_TAP_MS) {
lastTapTime = 0;
toggleEnlarged();
return;
}
lastTapTime = now;
}
const r = pipEl.getBoundingClientRect();
snapToCorner(pipEl, nearestCorner(r.left, r.top, pipEl));
}
function updateVisibility() { function updateVisibility() {
if (!pipEl) return; if (!pipEl) return;
if (isPipActive()) { if (isPipActive()) {
// Mobile PiP mode // Mobile PiP mode
if (inView) { if (inView) {
const pos = getCornerPos(corner, pipEl); pip.show(pipEl);
dragPos = pos;
pipEl.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
pipEl.style.opacity = '1';
} else { } else {
pipEl.style.opacity = '0'; pip.hide();
} }
} else { } else {
// Desktop (both modes): CSS handles everything // Desktop (both modes): CSS handles everything
@@ -161,7 +47,11 @@
function onResize() { function onResize() {
if (!pipEl) return; if (!pipEl) return;
updateVisibility(); if (isPipActive() && inView) {
pip.reposition();
} else {
updateVisibility();
}
} }
onMount(() => { onMount(() => {
@@ -193,11 +83,11 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="image-wrap" class="image-wrap"
class:enlarged class:enlarged={pip.enlarged}
bind:this={pipEl} bind:this={pipEl}
onpointerdown={onPointerDown} onpointerdown={pip.onpointerdown}
onpointermove={onPointerMove} onpointermove={pip.onpointermove}
onpointerup={onPointerUp} onpointerup={pip.onpointerup}
> >
<img {src} {alt}> <img {src} {alt}>
</div> </div>

164
src/lib/js/pip.svelte.ts Normal file
View File

@@ -0,0 +1,164 @@
/**
* Shared PiP (Picture-in-Picture) drag/snap/enlarge composable.
* Extracts duplicated mobile PiP logic from StickyImage and rosary page.
*/
interface PipOptions {
margin?: number;
tapThreshold?: number;
doubleTapMs?: number;
initialCorner?: Corner;
smallHeight?: number;
largeHeight?: number;
}
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export function createPip(opts: PipOptions = {}) {
const margin = opts.margin ?? 16;
const tapThreshold = opts.tapThreshold ?? 10;
const doubleTapMs = opts.doubleTapMs ?? 400;
const smallVh = opts.smallHeight ?? 25;
const largeVh = opts.largeHeight ?? 37.5;
let corner: Corner = $state(opts.initialCorner ?? 'bottom-right');
let dragging = $state(false);
let enlarged = $state(false);
let dragOffset = { x: 0, y: 0 };
let dragPos = { x: 0, y: 0 };
let dragMoved = false;
let lastTapTime = 0;
let el: HTMLElement | null = null;
function getCornerPos(c: Corner, target: HTMLElement) {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = target.getBoundingClientRect();
const positions: Record<Corner, { x: number; y: number }> = {
'top-left': { x: margin, y: margin },
'top-right': { x: vw - r.width - margin, y: margin },
'bottom-left': { x: margin, y: vh - r.height - margin },
'bottom-right': { x: vw - r.width - margin, y: vh - r.height - margin },
};
return positions[c];
}
function nearestCorner(x: number, y: number, target: HTMLElement): Corner {
const vw = window.innerWidth;
const vh = window.innerHeight;
const r = target.getBoundingClientRect();
const cx = x + r.width / 2;
const cy = y + r.height / 2;
const left = cx < vw / 2;
const top = cy < vh / 2;
return `${top ? 'top' : 'bottom'}-${left ? 'left' : 'right'}` as Corner;
}
function snapToCorner(target: HTMLElement, c: Corner) {
const pos = getCornerPos(c, target);
corner = c;
dragPos = pos;
target.style.transition = 'transform 0.25s ease';
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true });
}
function toggleEnlarged() {
if (!el) return;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight / 100;
const currentH = enlarged ? largeVh * vh : smallVh * vh;
const targetH = enlarged ? smallVh * vh : largeVh * vh;
const ratio = targetH / currentH;
enlarged = !enlarged;
const newW = rect.width * ratio;
const newH = rect.height * ratio;
let newX = rect.left;
let newY = rect.top;
if (corner.includes('right')) newX = rect.right - newW;
if (corner.includes('bottom')) newY = rect.bottom - newH;
dragPos = { x: newX, y: newY };
el.style.transition = 'transform 0.25s ease';
el.style.transform = `translate(${newX}px, ${newY}px)`;
el.addEventListener('transitionend', () => {
el!.style.transition = '';
}, { once: true });
}
function show(target: HTMLElement) {
el = target;
const pos = getCornerPos(corner, target);
dragPos = pos;
target.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
target.style.opacity = '1';
}
function hide() {
if (el) el.style.opacity = '0';
}
function reposition() {
if (!el) return;
const pos = getCornerPos(corner, el);
dragPos = pos;
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
}
function onpointerdown(e: PointerEvent) {
if (!el) return;
dragging = true;
dragMoved = false;
const r = el.getBoundingClientRect();
dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top };
el.setPointerCapture(e.pointerId);
el.style.transition = '';
e.preventDefault();
}
function onpointermove(e: PointerEvent) {
if (!dragging || !el) return;
const x = e.clientX - dragOffset.x;
const y = e.clientY - dragOffset.y;
if (!dragMoved) {
const dx = Math.abs(x - dragPos.x);
const dy = Math.abs(y - dragPos.y);
if (dx > tapThreshold || dy > tapThreshold) dragMoved = true;
}
dragPos = { x, y };
el.style.transform = `translate(${x}px, ${y}px)`;
}
function onpointerup(_e: PointerEvent) {
if (!dragging || !el) return;
dragging = false;
if (!dragMoved) {
const now = Date.now();
if (now - lastTapTime < doubleTapMs) {
lastTapTime = 0;
toggleEnlarged();
return;
}
lastTapTime = now;
}
const r = el.getBoundingClientRect();
snapToCorner(el, nearestCorner(r.left, r.top, el));
}
return {
get corner() { return corner; },
get dragging() { return dragging; },
get enlarged() { return enlarged; },
show,
hide,
reposition,
onpointerdown,
onpointermove,
onpointerup
};
}

View File

@@ -1,6 +1,7 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { createLanguageContext } from "$lib/contexts/languageContext.js"; import { createLanguageContext } from "$lib/contexts/languageContext.js";
import { createPip } from "$lib/js/pip.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";
@@ -352,6 +353,25 @@ function getMysteryImage(mystery, section) {
} }
const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection)); const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection));
// Mobile PiP drag/enlarge
const pip = createPip();
let rosaryPipEl = $state(null);
let lastPipSrc = $state(null);
function isMobilePip() {
return !window.matchMedia('(min-width: 900px)').matches;
}
$effect(() => {
if (mysteryPipSrc) lastPipSrc = mysteryPipSrc;
if (!rosaryPipEl || !isMobilePip()) return;
if (mysteryPipSrc) {
pip.show(rosaryPipEl);
} else {
pip.hide();
}
});
let mysteryImageContainer; let mysteryImageContainer;
let mysteryScrollRaf = null; let mysteryScrollRaf = null;
@@ -491,6 +511,14 @@ onMount(() => {
// Now allow saving to localStorage // Now allow saving to localStorage
hasLoadedFromStorage = true; hasLoadedFromStorage = true;
// PiP resize handler
const onPipResize = () => {
if (rosaryPipEl && isMobilePip() && mysteryPipSrc) {
pip.reposition();
}
};
window.addEventListener('resize', onPipResize);
let scrollLock = null; // Track which side initiated the scroll: 'prayer', 'svg', or 'click' let scrollLock = null; // Track which side initiated the scroll: 'prayer', 'svg', or 'click'
let scrollLockTimeout = null; let scrollLockTimeout = null;
@@ -835,6 +863,7 @@ onMount(() => {
clearTimeout(scrollLockTimeout); clearTimeout(scrollLockTimeout);
clearTimeout(svgScrollTimeout); clearTimeout(svgScrollTimeout);
window.removeEventListener('scroll', handleWindowScroll); window.removeEventListener('scroll', handleWindowScroll);
window.removeEventListener('resize', onPipResize);
if (svgContainer) { if (svgContainer) {
svgContainer.removeEventListener('scroll', handleSvgScroll); svgContainer.removeEventListener('scroll', handleSvgScroll);
} }
@@ -1392,15 +1421,17 @@ h1 {
/* Mobile PiP for mystery images */ /* Mobile PiP for mystery images */
.mystery-pip { .mystery-pip {
position: fixed; position: fixed;
bottom: 16px; top: 0;
right: 16px; left: 0;
z-index: 10000; z-index: 10000;
pointer-events: none;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; touch-action: none;
cursor: grab;
user-select: none;
transition: opacity 0.25s ease;
} }
.mystery-pip.visible { .mystery-pip:active {
opacity: 1; cursor: grabbing;
} }
.mystery-pip img { .mystery-pip img {
height: 25vh; height: 25vh;
@@ -1408,6 +1439,11 @@ h1 {
object-fit: contain; object-fit: contain;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 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;
} }
@media (min-width: 900px) { @media (min-width: 900px) {
.mystery-pip { .mystery-pip {
@@ -1819,9 +1855,18 @@ h1 {
<!-- Mobile PiP for mystery images --> <!-- Mobile PiP for mystery images -->
{#if hasMysteryImages} {#if hasMysteryImages}
<div class="mystery-pip" class:visible={!!mysteryPipSrc}> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#if mysteryPipSrc} <div
<img src={mysteryPipSrc} alt=""> class="mystery-pip"
class:visible={!!mysteryPipSrc}
class:enlarged={pip.enlarged}
bind:this={rosaryPipEl}
onpointerdown={pip.onpointerdown}
onpointermove={pip.onpointermove}
onpointerup={pip.onpointerup}
>
{#if lastPipSrc}
<img src={lastPipSrc} alt="">
{/if} {/if}
</div> </div>
{/if} {/if}