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:
+77
-24
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
* {
|
* {
|
||||||
|
|||||||
Reference in New Issue
Block a user