From 6f9c486774568c1d58771fd34d92def1ea5d99de Mon Sep 17 00:00:00 2001 From: olcxja Date: Wed, 30 Sep 2026 19:02:41 +0200 Subject: [PATCH] tuff popup transition --- renderer/app.js | 53 ++++++++++++++++++++++++++++++++++++++++++++-- renderer/style.css | 35 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 009e501..484c407 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -109,6 +109,7 @@ function toast(message, kind) { // import mode -> prefix picker happens within milliseconds), so showModal // cancels any pending teardown and clears the closing state. let modalTeardownTimer = null +let modalSwapTimer = null function showModal(html) { if (modalTeardownTimer) { @@ -118,12 +119,55 @@ function showModal(html) { const overlay = qs('#modal-overlay') const modal = qs('#modal') modal.classList.remove('closing') - qs('#modal').innerHTML = html + // Opening over an already-visible modal (import mode -> prefix picker): + // morph instead of close-then-open - the box glides to the new height + // while old content dissolves into the new one. + const swapping = overlay.style.display !== 'none' && !!modal.innerHTML.trim() + if (swapping) swapModalContent(modal, html) + else modal.innerHTML = html overlay.style.display = 'flex' requestAnimationFrame(() => requestAnimationFrame(() => overlay.classList.add('show'))) return qs('#modal') } +// Animated content swap while the modal stays open. The new HTML is put in +// SYNCHRONOUSLY (callers wire their buttons right after showModal returns); +// an opaque snapshot of the old content is laid on top and fades out while +// the box animates to the new content's height. Same feel as switching tabs. +function swapModalContent(modal, html) { + if (modalSwapTimer) { + clearTimeout(modalSwapTimer) + modalSwapTimer = null + } + const prevGhost = modal.querySelector('.modal-ghost') + if (prevGhost) prevGhost.remove() // rapid double swap: snapshot clean state + modal.classList.remove('swapping') + const oldH = modal.offsetHeight + const ghost = document.createElement('div') + ghost.className = 'modal-ghost' + ghost.innerHTML = modal.innerHTML + modal.classList.add('resizing') // clip content while the box glides + modal.style.height = oldH + 'px' + modal.innerHTML = html + // measure the new natural height without an intermediate paint + modal.style.height = 'auto' + const newH = modal.offsetHeight + modal.style.height = oldH + 'px' + void modal.offsetHeight // flush styles so the height transition starts here + modal.appendChild(ghost) + void ghost.offsetHeight // commit the ghost's initial opacity:1 so the fade transition runs + requestAnimationFrame(() => { + modal.style.height = newH + 'px' + modal.classList.add('swapping') // fades the ghost out over the new content + modalSwapTimer = setTimeout(() => { + modalSwapTimer = null + ghost.remove() + modal.classList.remove('swapping', 'resizing') + modal.style.height = '' // back to auto (dropdowns may overflow again) + }, 240) + }) +} + function closeModal() { const overlay = qs('#modal-overlay') const modal = qs('#modal') @@ -133,8 +177,13 @@ function closeModal() { if (modalTeardownTimer) clearTimeout(modalTeardownTimer) modalTeardownTimer = setTimeout(() => { modalTeardownTimer = null + if (modalSwapTimer) { + clearTimeout(modalSwapTimer) + modalSwapTimer = null + } overlay.style.display = 'none' - modal.classList.remove('closing') + modal.classList.remove('closing', 'swapping', 'resizing') + modal.style.height = '' modal.innerHTML = '' }, 160) } diff --git a/renderer/style.css b/renderer/style.css index 2af013a..ed7e8f5 100644 --- a/renderer/style.css +++ b/renderer/style.css @@ -603,6 +603,7 @@ select { opacity: 0; transform: scale(0.6); transition: all 0.2s var(--spring); + position: relative; } .modal-overlay.show .modal { opacity: 1; @@ -613,6 +614,40 @@ select { transform: scale(0.8); transition: all 0.15s ease; } +/* --- animated content swap (import mode -> prefix picker etc.) -------------- */ +/* During the morph the box keeps a fixed pixel height and glides to the new + content's height; overflow is clipped ONLY then (a permanent clip would + cut off open custom-select dropdowns). */ +.modal.resizing { + overflow: hidden; + transition: height 0.22s var(--spring); + will-change: height; +} +/* Opaque snapshot of the OLD content laid over the new one; fades out while + the box glides -> content dissolves from old to new (like switching tabs). */ +.modal .modal-ghost { + position: absolute; + inset: 1.4rem 1.6rem; /* overlay the box's own padding area */ + z-index: 5; + pointer-events: none; + background: var(--panel-color); + display: flex; + flex-direction: column; + gap: 0.8rem; /* mirror the modal's layout so the snapshot aligns 1:1 */ + opacity: 1; +} +.modal.swapping .modal-ghost { + transition: opacity 0.18s ease; + opacity: 0; +} +/* new content fades in under the fading-out ghost -> real crossfade */ +.modal.swapping > :not(.modal-ghost) { + animation: modal-content-in 0.2s ease; +} +@keyframes modal-content-in { + from { opacity: 0; } + to { opacity: 1; } +} .modal h2 { font-family: 'Nunito Black', 'Nunito', sans-serif; font-size: 1.2rem;