Skip to content

Commit 802bda6

Browse files
committed
improve animation logic
1 parent 53d966d commit 802bda6

3 files changed

Lines changed: 113 additions & 102 deletions

File tree

css/cards.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ body.shadow-on .app-card {
4242
transition: none;
4343
}
4444

45+
/* --- VIEW TRANSITION TUNING (The Snappy Fix) --- */
46+
/* Target all view transition groups to apply consistent easing */
47+
::view-transition-group(*) {
48+
animation-duration: 0.3s;
49+
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); /* Fast start, smooth stop */
50+
}
51+
52+
/* Remove cross-fade for a solid slide feel */
53+
::view-transition-old(*),
54+
::view-transition-new(*) {
55+
mix-blend-mode: normal;
56+
height: 100%;
57+
width: 100%;
58+
}
59+
4560
/* Base Ghost */
4661
.grid-ghost {
4762
border: 2px dashed rgba(255, 255, 255, 0.3);

js/events.js

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// js/events.js
21
import { state, setState } from "./state.js";
32
import { qs } from "./dom.js";
43
import { renderGrid, applyGridPosition, saveGridState } from "./grid.js";
@@ -30,6 +29,11 @@ export function initGlobalEvents() {
3029
if (gridLines) gridRect = gridLines.getBoundingClientRect();
3130
else return;
3231

32+
if (actItem.style.viewTransitionName) {
33+
actItem.dataset.vtn = actItem.style.viewTransitionName;
34+
actItem.style.viewTransitionName = '';
35+
}
36+
3337
if (e.target.closest('.resize-handle')) {
3438
mode = 'resize';
3539
initResizeX = e.clientX;
@@ -157,18 +161,12 @@ export function initGlobalEvents() {
157161
return;
158162
}
159163

160-
if (mode === 'move') {
161-
// CAPTURE DROP POSITION BEFORE RESET
162-
const dropRect = actItem.getBoundingClientRect();
163-
164-
actItem.classList.remove('moving');
165-
actItem.style.position = '';
166-
actItem.style.width = '';
167-
actItem.style.height = '';
168-
actItem.style.left = '';
169-
actItem.style.top = '';
170-
actItem.style.zIndex = '';
164+
if (actItem.dataset.vtn) {
165+
actItem.style.viewTransitionName = actItem.dataset.vtn;
166+
delete actItem.dataset.vtn;
167+
}
171168

169+
if (mode === 'move') {
172170
if (lastMoveResult && lastMoveResult.possible) {
173171
app.x = lastMoveResult.targetX;
174172
app.y = lastMoveResult.targetY;
@@ -179,11 +177,12 @@ export function initGlobalEvents() {
179177
});
180178
}
181179
saveGridState();
182-
// PASS DROP RECT TO RENDERER FOR ANIMATION
183-
renderGrid({ id: app.id, rect: dropRect });
180+
// SUCCESS: Update Data + Render (with Snap)
181+
renderGrid({ id: app.id, dropType: 'success' });
184182
} else {
185-
applyGridPosition(actItem, app.x, app.y, app.cols, app.rows);
186-
renderGrid();
183+
// FAILURE: No Data Change + Render (with Animation)
184+
// We do NOT manually reset actItem styles here; renderGrid handles it
185+
renderGrid({ id: app.id, dropType: 'fail' });
187186
}
188187
}
189188
else if (mode === 'resize') {

js/grid.js

Lines changed: 83 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -6,120 +6,113 @@ import { registry } from "./registry.js";
66
import { VirtualGrid } from "./grid/virtualGrid.js";
77

88
// -----------------------------
9-
// GRID RENDERING (Reconciliation + FLIP Animation)
9+
// GRID RENDERING (View Transitions API)
1010
// -----------------------------
1111

12+
/**
13+
* @param {Object} dragInfo - { id: number, dropType: 'success' | 'fail' }
14+
*/
1215
export async function renderGrid(dragInfo = null) {
1316
const dashboard = qs('#dashboard');
1417
if (!dashboard) return;
1518

1619
renderGridLines();
1720

18-
const apps = state.apps;
19-
const domMap = new Map();
20-
const prevRects = new Map();
21-
22-
// 1. Snapshot OLD Positions (First)
23-
qsa('.app-card', dashboard).forEach(el => {
24-
const id = parseInt(el.dataset.id);
25-
if (id) {
26-
domMap.set(id, el);
27-
prevRects.set(id, el.getBoundingClientRect());
21+
// Helper to perform the actual DOM updates
22+
const updateDOM = async () => {
23+
24+
// --- 1. FORCE CLEANUP (Moved outside the loop for safety) ---
25+
// This guarantees the floating card is reset, even if the state loop is busy.
26+
if (dragInfo && dragInfo.id) {
27+
const draggedEl = document.getElementById(`app-${dragInfo.id}`);
28+
if (draggedEl) {
29+
draggedEl.classList.remove('moving');
30+
draggedEl.style.position = '';
31+
draggedEl.style.width = '';
32+
draggedEl.style.height = '';
33+
draggedEl.style.left = '';
34+
draggedEl.style.top = '';
35+
draggedEl.style.zIndex = '';
36+
}
2837
}
29-
});
3038

31-
// Override the dragged item's "Old" position with its "Floating" position
32-
if (dragInfo && dragInfo.id && dragInfo.rect) {
33-
prevRects.set(dragInfo.id, dragInfo.rect);
34-
}
39+
const apps = state.apps;
40+
const domMap = new Map();
3541

36-
// 2. Update DOM (Last)
37-
for (const app of apps) {
38-
let el = domMap.get(app.id);
42+
qsa('.app-card', dashboard).forEach(el => {
43+
const id = parseInt(el.dataset.id);
44+
if (id) domMap.set(id, el);
45+
});
3946

40-
if (el) {
41-
// Check for position/size changes
42-
const currentX = parseInt(el.dataset.x);
43-
const currentY = parseInt(el.dataset.y);
44-
const currentW = parseInt(el.dataset.cols);
45-
const currentH = parseInt(el.dataset.rows);
47+
for (const app of apps) {
48+
let el = domMap.get(app.id);
4649

47-
if (currentX !== app.x || currentY !== app.y || currentW !== app.cols || currentH !== app.rows) {
48-
applyGridPosition(el, app.x, app.y, app.cols, app.rows);
49-
}
50+
if (el) {
51+
// UPDATE EXISTING
52+
// Ensure Transition Name is set
53+
if (!el.style.viewTransitionName) {
54+
el.style.viewTransitionName = `app-${app.id}`;
55+
}
5056

51-
// Check for content changes
52-
const dataHash = JSON.stringify(app.data || {}) + app.name;
53-
const currentHash = el.dataset.contentHash;
57+
const currentX = parseInt(el.dataset.x);
58+
const currentY = parseInt(el.dataset.y);
59+
const currentW = parseInt(el.dataset.cols);
60+
const currentH = parseInt(el.dataset.rows);
5461

55-
if (app.data?.bgColor) el.style.backgroundColor = app.data.bgColor;
56-
if (app.data?.textColor) el.style.color = app.data.textColor;
62+
if (currentX !== app.x || currentY !== app.y || currentW !== app.cols || currentH !== app.rows) {
63+
applyGridPosition(el, app.x, app.y, app.cols, app.rows);
64+
}
5765

58-
if (dataHash !== currentHash) {
59-
await mountAppContent(el, app);
60-
el.dataset.contentHash = dataHash;
61-
}
66+
// Content Check
67+
const dataHash = JSON.stringify(app.data || {}) + app.name;
68+
const currentHash = el.dataset.contentHash;
6269

63-
domMap.delete(app.id);
64-
} else {
65-
// Create New
66-
el = await createAppElement(app);
67-
dashboard.appendChild(el);
68-
}
69-
}
70+
if (app.data?.bgColor) el.style.backgroundColor = app.data.bgColor;
71+
if (app.data?.textColor) el.style.color = app.data.textColor;
7072

71-
// Cleanup removed apps
72-
domMap.forEach(el => el.remove());
73+
if (dataHash !== currentHash) {
74+
await mountAppContent(el, app);
75+
el.dataset.contentHash = dataHash;
76+
}
7377

74-
// 3. Invert & Play (Animate)
75-
// Double RAF ensures the DOM update is fully processed before we calculate deltas
76-
requestAnimationFrame(() => {
77-
const animations = [];
78+
domMap.delete(app.id);
79+
} else {
80+
// CREATE NEW
81+
el = await createAppElement(app);
82+
dashboard.appendChild(el);
83+
}
84+
}
7885

79-
apps.forEach(app => {
80-
const el = document.getElementById(`app-${app.id}`);
81-
const oldRect = prevRects.get(app.id);
86+
// Cleanup removed apps
87+
domMap.forEach(el => el.remove());
88+
};
8289

83-
if (el && oldRect) {
84-
const newRect = el.getBoundingClientRect();
90+
// --- THE ANIMATION TRIGGER ---
91+
if (document.startViewTransition) {
92+
let styleTag = null;
8593

86-
// Calculate Delta
87-
const dX = oldRect.left - newRect.left;
88-
const dY = oldRect.top - newRect.top;
94+
// Success: Snap instantly. Fail: Animate back.
95+
if (dragInfo && dragInfo.id && dragInfo.dropType === 'success') {
96+
styleTag = document.createElement('style');
97+
styleTag.innerHTML = `
98+
::view-transition-group(app-${dragInfo.id}) {
99+
animation-duration: 0s !important;
100+
}
101+
`;
102+
document.head.appendChild(styleTag);
103+
}
89104

90-
// Only animate significant moves
91-
if (Math.abs(dX) > 1 || Math.abs(dY) > 1) {
92-
// INVERT (Start State): Move back to old position instantly
93-
el.style.transition = 'none';
94-
el.style.transform = `translate(${dX}px, ${dY}px)`;
95-
el.style.zIndex = '100'; // Float above others
105+
const transition = document.startViewTransition(() => updateDOM());
96106

97-
animations.push(el);
98-
}
99-
}
107+
transition.finished.finally(() => {
108+
if (styleTag) styleTag.remove();
100109
});
101110

102-
// PLAY (End State): Remove transform smoothly
103-
// Nested RAF forces the browser to paint the 'Invert' state first
104-
requestAnimationFrame(() => {
105-
animations.forEach(el => {
106-
el.style.transition = 'transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1)';
107-
el.style.transform = '';
108-
109-
// Cleanup Z-Index
110-
const cleanup = () => {
111-
el.style.zIndex = '';
112-
el.style.transition = '';
113-
el.removeEventListener('transitionend', cleanup);
114-
};
115-
el.addEventListener('transitionend', cleanup);
116-
});
117-
});
118-
});
111+
} else {
112+
updateDOM();
113+
}
119114
}
120115

121-
// ... (Rest of helpers remain identical) ...
122-
123116
async function createAppElement(app) {
124117
const el = createEl('div', {
125118
class: 'app-card',
@@ -129,6 +122,8 @@ async function createAppElement(app) {
129122
}
130123
});
131124

125+
el.style.viewTransitionName = `app-${app.id}`;
126+
132127
applyGridPosition(el, app.x, app.y, app.cols, app.rows);
133128

134129
if (app.data?.bgColor) el.style.backgroundColor = app.data.bgColor;
@@ -141,6 +136,8 @@ async function createAppElement(app) {
141136
return el;
142137
}
143138

139+
// ... (Rest of helpers remain unchanged) ...
140+
144141
async function mountAppContent(el, app) {
145142
const appDef = registry.get(app.subtype);
146143
let innerHTML = 'Unknown App';

0 commit comments

Comments
 (0)