Add FLIP tab animations for open, close, and reorder

Implements FLIP (First, Last, Invert, Play) animations for tab reordering, opening, and closing in the tab bar. Tabs now animate smoothly when added, removed, or reordered, improving visual feedback and user experience. CSS classes and keyframes for enter and exit transitions are introduced, and the tab rendering logic is updated to measure and animate tab positions.
This commit is contained in:
2025-08-10 11:26:54 +12:00
parent 6b3b8465ce
commit a69b6195d1
2 changed files with 107 additions and 25 deletions
+77 -24
View File
@@ -59,6 +59,9 @@ let bookmarks = [];
// Efficient render scheduling to avoid redundant DOM work // Efficient render scheduling to avoid redundant DOM work
let tabsRenderPending = false; let tabsRenderPending = false;
// Track previous order and positions for FLIP animations
let lastTabOrder = [];
let closingTabs = new Set();
function scheduleRenderTabs() { function scheduleRenderTabs() {
if (tabsRenderPending) return; if (tabsRenderPending) return;
tabsRenderPending = true; tabsRenderPending = true;
@@ -464,34 +467,70 @@ function setActiveTab(id) {
} }
function closeTab(id) { function closeTab(id) {
// Play closing animation on tab button, then remove
const btn = tabBarEl.querySelector(`[data-tab-id="${id}"]`);
if (btn && !closingTabs.has(id)) {
closingTabs.add(id);
btn.classList.add('tab--closing');
// Pre-calc which tab should become active if we're closing the active tab
const idx = tabs.findIndex(t => t.id === id);
const nextActiveId = (id === activeTabId)
? (tabs[idx - 1]?.id ?? tabs[idx + 1]?.id ?? tabs[0]?.id)
: activeTabId;
btn.addEventListener('animationend', () => {
// Remove webview
const w = document.getElementById(`tab-${id}`); const w = document.getElementById(`tab-${id}`);
if (w) w.remove(); if (w) w.remove();
// Remove from model
tabs = tabs.filter(t => t.id !== id); tabs = tabs.filter(t => t.id !== id);
// Choose a new active tab if needed
if (id === activeTabId) { if (tabs.length > 0 && nextActiveId) setActiveTab(nextActiveId);
if (tabs.length > 0) setActiveTab(tabs[0].id); closingTabs.delete(id);
scheduleRenderTabs();
updateNavButtons();
}, { once: true });
return;
} }
// Fallback (no button rendered yet)
const w = document.getElementById(`tab-${id}`);
if (w) w.remove();
tabs = tabs.filter(t => t.id !== id);
if (id === activeTabId && tabs.length > 0) setActiveTab(tabs[0].id);
scheduleRenderTabs(); scheduleRenderTabs();
updateNavButtons(); updateNavButtons();
} }
// 2) streamline renderTabs with a fragment // 2) streamline renderTabs with a fragment
function renderTabs() { function renderTabs() {
// Measure initial positions (First) for existing elements
const firstRects = new Map();
const existing = Array.from(tabBarEl.querySelectorAll('.tab'));
existing.forEach(el => {
firstRects.set(el.dataset.tabId, el.getBoundingClientRect());
});
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
// ensure tablist role present
if (tabBarEl && tabBarEl.getAttribute('role') !== 'tablist') { if (tabBarEl && tabBarEl.getAttribute('role') !== 'tablist') {
tabBarEl.setAttribute('role', 'tablist'); tabBarEl.setAttribute('role', 'tablist');
} }
// Create tab elements
const currentOrder = [];
tabs.forEach(tab => { tabs.forEach(tab => {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'tab' + (tab.id === activeTabId ? ' active' : ''); el.className = 'tab' + (tab.id === activeTabId ? ' active' : '');
el.classList.add('tab--flip');
el.setAttribute('role', 'tab'); el.setAttribute('role', 'tab');
el.setAttribute('aria-selected', String(tab.id === activeTabId)); el.setAttribute('aria-selected', String(tab.id === activeTabId));
el.setAttribute('tabindex', tab.id === activeTabId ? '0' : '-1'); el.setAttribute('tabindex', tab.id === activeTabId ? '0' : '-1');
el.dataset.tabId = tab.id;
currentOrder.push(tab.id);
if (!lastTabOrder.includes(tab.id)) {
// New tab enters with animation
el.classList.add('tab--enter');
}
// favicon
if (tab.favicon) { if (tab.favicon) {
const icon = document.createElement('img'); const icon = document.createElement('img');
icon.src = tab.favicon; icon.src = tab.favicon;
@@ -499,13 +538,11 @@ function renderTabs() {
el.appendChild(icon); el.appendChild(icon);
} }
// title
const title = document.createElement('span'); const title = document.createElement('span');
title.className = 'tab-title'; title.className = 'tab-title';
title.textContent = getTabLabel(tab); title.textContent = getTabLabel(tab);
el.appendChild(title); el.appendChild(title);
// close
const closeBtn = document.createElement('button'); const closeBtn = document.createElement('button');
closeBtn.className = 'tab-close'; closeBtn.className = 'tab-close';
closeBtn.title = 'Close tab'; closeBtn.title = 'Close tab';
@@ -516,26 +553,19 @@ function renderTabs() {
}); });
el.appendChild(closeBtn); el.appendChild(closeBtn);
// middle-click to close
el.addEventListener('mousedown', (e) => { el.addEventListener('mousedown', (e) => {
if (e.button === 1) { // middle if (e.button === 1) {
e.preventDefault(); e.preventDefault();
closeTab(tab.id); closeTab(tab.id);
} }
}); });
// make tab draggable
el.draggable = true; el.draggable = true;
el.addEventListener('dragstart', e => { el.addEventListener('dragstart', e => {
e.dataTransfer.setData('tabId', tab.id); e.dataTransfer.setData('tabId', tab.id);
// for Firefox compatibility
e.dataTransfer.setData('text/plain', tab.id); e.dataTransfer.setData('text/plain', tab.id);
}); });
el.addEventListener('dragover', e => { e.preventDefault(); });
// allow drop reordering
el.addEventListener('dragover', e => {
e.preventDefault();
});
el.addEventListener('drop', e => { el.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
const draggedId = e.dataTransfer.getData('tabId') || e.dataTransfer.getData('text/plain'); const draggedId = e.dataTransfer.getData('tabId') || e.dataTransfer.getData('text/plain');
@@ -543,7 +573,6 @@ function renderTabs() {
const fromIndex = tabs.findIndex(t => t.id === draggedId); const fromIndex = tabs.findIndex(t => t.id === draggedId);
const toIndex = tabs.findIndex(t => t.id === tab.id); const toIndex = tabs.findIndex(t => t.id === tab.id);
if (fromIndex === -1 || toIndex === -1) return; if (fromIndex === -1 || toIndex === -1) return;
// determine insert position: before or after depending on cursor
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const after = (e.clientX - rect.left) > rect.width / 2; const after = (e.clientX - rect.left) > rect.width / 2;
const newIndex = toIndex + (after ? 1 : 0); const newIndex = toIndex + (after ? 1 : 0);
@@ -552,8 +581,6 @@ function renderTabs() {
tabs.splice(adjIndex, 0, moved); tabs.splice(adjIndex, 0, moved);
scheduleRenderTabs(); scheduleRenderTabs();
}); });
// tear off to new window if drag ends outside window
el.addEventListener('dragend', e => { el.addEventListener('dragend', e => {
if ( if (
e.clientX < 0 || e.clientX > window.innerWidth || e.clientX < 0 || e.clientX > window.innerWidth ||
@@ -567,7 +594,8 @@ function renderTabs() {
el.addEventListener('click', () => setActiveTab(tab.id)); el.addEventListener('click', () => setActiveTab(tab.id));
frag.appendChild(el); frag.appendChild(el);
}); });
// Dedicated new-tab button at end
// New tab button
const plus = document.createElement('button'); const plus = document.createElement('button');
plus.className = 'new-tab-button'; plus.className = 'new-tab-button';
plus.title = 'New tab'; plus.title = 'New tab';
@@ -576,8 +604,33 @@ function renderTabs() {
plus.addEventListener('click', () => createTab()); plus.addEventListener('click', () => createTab());
frag.appendChild(plus); frag.appendChild(plus);
tabBarEl.innerHTML = ''; // clear once // Swap DOM: to support FLIP, we need to keep the old nodes around until we can measure Last
tabBarEl.appendChild(frag); // append in one shot tabBarEl.innerHTML = '';
tabBarEl.appendChild(frag);
// Measure final positions (Last)
const lastRects = new Map();
Array.from(tabBarEl.querySelectorAll('.tab')).forEach(el => {
lastRects.set(el.dataset.tabId, el.getBoundingClientRect());
});
// Apply FLIP: invert then play
Array.from(tabBarEl.querySelectorAll('.tab')).forEach(el => {
const id = el.dataset.tabId;
const first = firstRects.get(id);
const last = lastRects.get(id);
if (!first || !last) return;
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx || dy) {
el.style.transform = `translate(${dx}px, ${dy}px)`;
el.getBoundingClientRect(); // force reflow
el.style.transform = '';
}
});
// Update order for next render
lastTabOrder = currentOrder.slice();
} }
// 1) handle URL sent by main for a detached window // 1) handle URL sent by main for a detached window
+29
View File
@@ -326,6 +326,35 @@ html, body {
background: #555; background: #555;
} }
/* Tab animations */
.tab--flip {
transition: transform 180ms cubic-bezier(0.2, 0, 0, 1);
}
.tab--enter {
animation: tab-enter 160ms ease-out both;
}
.tab--closing {
animation: tab-exit 140ms ease-in both;
}
@keyframes tab-enter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes tab-exit {
to {
opacity: 0;
transform: translateY(-6px) scale(0.95);
}
}
/* Respect reduced motion preferences */ /* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { * {