page-flip-animation.js (8.4 KB)
1 // Page flip animation state 2 let isAnimating = false; 3 let currentSpreadIndex = 0; 4 let pendingFlipQueue = []; 5 let bookContainer = null; 6 7 // Get animation state 8 export function getIsAnimating() { 9 return isAnimating; 10 } 11 12 // Page structure for flip animation 13 // Each "leaf" has a front and back side 14 // front-cover/page1 are on opposite sides of the same leaf 15 // page2/page3 are on opposite sides of the next leaf, etc. 16 const PAGE_LEAVES = [ 17 { front: 'front-cover', back: 'page1' }, // Leaf 0 18 { front: 'page2', back: 'page3' }, // Leaf 1 19 { front: 'page4', back: 'page5' }, // Leaf 2 20 { front: 'page6', back: 'back-cover' } // Leaf 3 21 ]; 22 23 // Initialize the 3D page flip system 24 export function initPageFlip(container, doc) { 25 currentSpreadIndex = 0; 26 27 // Create the book structure 28 const book = doc.createElement('div'); 29 book.className = 'zine-book'; 30 bookContainer = book; 31 32 // Create all leaves (page pairs) 33 PAGE_LEAVES.forEach((leaf, index) => { 34 const leafEl = doc.createElement('div'); 35 leafEl.className = 'zine-leaf'; 36 leafEl.dataset.leafIndex = index; 37 leafEl.dataset.state = 'closed'; // closed, flipping, open 38 39 // Front side of the leaf 40 const frontSide = doc.createElement('div'); 41 frontSide.className = 'zine-leaf-front'; 42 const frontPage = doc.getElementById(leaf.front); 43 if (frontPage) { 44 frontSide.appendChild(frontPage.cloneNode(true)); 45 } 46 47 // Back side of the leaf 48 const backSide = doc.createElement('div'); 49 backSide.className = 'zine-leaf-back'; 50 const backPage = doc.getElementById(leaf.back); 51 if (backPage) { 52 backSide.appendChild(backPage.cloneNode(true)); 53 } 54 55 leafEl.appendChild(frontSide); 56 leafEl.appendChild(backSide); 57 book.appendChild(leafEl); 58 }); 59 60 container.appendChild(book); 61 62 // Hide original pages in body 63 const originalPages = doc.querySelectorAll('.page:not(.zine-base-page)'); 64 originalPages.forEach(page => { 65 page.style.display = 'none'; 66 }); 67 68 // Set initial state - all leaves closed, showing front cover 69 updateLeafStates(0, doc); 70 updateBookPosition(0, false); 71 } 72 73 // Calculate the shift amount for a given spread 74 function getShiftAmountForSpread(spreadIndex) { 75 // Spread 0 (front cover): single right page, shift left to center 76 // Spread 4 (back cover): single left page, shift right to center 77 // margin-left is doubled to compensate for flex re-centering, so the net visual 78 // shift is half the margin value (net visual shift = half a page width = 1.375in) 79 if (spreadIndex === 0) { 80 return '-2.75in'; 81 } else if (spreadIndex === 4) { 82 return '2.75in'; 83 } 84 return '0in'; 85 } 86 87 // Update the book container horizontal position based on spread 88 // Spreads 0 and 4 (single pages) should be centered, others at normal position 89 // Uses margin-left instead of translateX to avoid conflicts with preserve-3d and zoom 90 function updateBookPosition(spreadIndex, animated = true) { 91 if (!bookContainer) return; 92 93 const shiftAmount = getShiftAmountForSpread(spreadIndex); 94 95 if (animated) { 96 bookContainer.style.transition = 'margin-left 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)'; 97 bookContainer.style.marginLeft = shiftAmount; 98 99 // Remove transition after animation completes 100 setTimeout(() => { 101 bookContainer.style.transition = ''; 102 }, 650); 103 } else { 104 bookContainer.style.transition = 'none'; 105 bookContainer.style.marginLeft = shiftAmount; 106 bookContainer.offsetHeight; 107 bookContainer.style.transition = ''; 108 } 109 } 110 111 // Update which leaves are open/closed based on current spread 112 function updateLeafStates(spreadIndex, doc) { 113 const leaves = doc.querySelectorAll('.zine-leaf'); 114 115 leaves.forEach((leaf, index) => { 116 // Spread 0 = front cover (no leaves flipped) 117 // Spread 1 = pages 1-2 (leaf 0 flipped) 118 // Spread 2 = pages 3-4 (leaves 0-1 flipped) 119 // Spread 3 = pages 5-6 (leaves 0-2 flipped) 120 // Spread 4 = back cover (all leaves flipped) 121 122 if (index < spreadIndex) { 123 // This leaf should be flipped to the left (showing back) 124 leaf.dataset.state = 'open'; 125 leaf.style.transform = 'rotateY(-180deg)'; 126 // Open leaves on left have lower z-index 127 leaf.style.zIndex = String(index + 1); 128 } else { 129 // This leaf should be closed (on the right, showing front) 130 leaf.dataset.state = 'closed'; 131 leaf.style.transform = 'rotateY(0deg)'; 132 // Closed leaves stack with first on top 133 leaf.style.zIndex = String(20 - index); 134 } 135 }); 136 137 } 138 139 // Process next queued flip if any 140 function processNextFlip() { 141 if (pendingFlipQueue.length === 0) { 142 isAnimating = false; 143 return; 144 } 145 146 const nextFlip = pendingFlipQueue.shift(); 147 // Use current spread index as fromSpread since we're now at a different position 148 executePageFlip(currentSpreadIndex, nextFlip.toSpread, nextFlip.doc, nextFlip.onComplete); 149 } 150 151 // Execute a single page flip (internal function) 152 function executePageFlip(fromSpread, toSpread, doc, onComplete) { 153 isAnimating = true; 154 currentSpreadIndex = toSpread; 155 156 // Update book position synchronously with the flip animation 157 updateBookPosition(toSpread, true); 158 159 const direction = toSpread > fromSpread ? 'forward' : 'backward'; 160 const leaves = doc.querySelectorAll('.zine-leaf'); 161 162 if (direction === 'forward') { 163 // Flipping forward (right to left) 164 const leafToFlip = leaves[fromSpread]; 165 if (leafToFlip) { 166 leafToFlip.dataset.state = 'flipping'; 167 leafToFlip.style.zIndex = '100'; // Put it on top during flip 168 169 // Enable transition 170 leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)'; 171 172 // Trigger the flip 173 requestAnimationFrame(() => { 174 leafToFlip.style.transform = 'rotateY(-180deg)'; 175 }); 176 177 // Clean up after animation 178 let cleanupCalled = false; 179 const cleanup = () => { 180 if (cleanupCalled) return; 181 cleanupCalled = true; 182 183 leafToFlip.dataset.state = 'open'; 184 leafToFlip.style.transition = ''; 185 // Set z-index for open state (lower than closed leaves) 186 leafToFlip.style.zIndex = String(fromSpread + 1); 187 if (onComplete) onComplete(); 188 leafToFlip.removeEventListener('transitionend', cleanup); 189 clearTimeout(timeoutId); 190 // Process next flip in queue 191 processNextFlip(); 192 }; 193 leafToFlip.addEventListener('transitionend', cleanup); 194 195 // Fallback timeout in case transitionend doesn't fire 196 const timeoutId = setTimeout(cleanup, 800); 197 } 198 } else { 199 // Flipping backward (left to right) 200 const leafToFlip = leaves[toSpread]; 201 if (leafToFlip) { 202 leafToFlip.dataset.state = 'flipping'; 203 leafToFlip.style.zIndex = '100'; // Put it on top during flip 204 205 // Enable transition 206 leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)'; 207 208 // Trigger the flip 209 requestAnimationFrame(() => { 210 leafToFlip.style.transform = 'rotateY(0deg)'; 211 }); 212 213 // Clean up after animation 214 let cleanupCalled = false; 215 const cleanup = () => { 216 if (cleanupCalled) return; 217 cleanupCalled = true; 218 219 leafToFlip.dataset.state = 'closed'; 220 leafToFlip.style.transition = ''; 221 // Set z-index for closed state (higher than open leaves) 222 leafToFlip.style.zIndex = String(20 - toSpread); 223 if (onComplete) onComplete(); 224 leafToFlip.removeEventListener('transitionend', cleanup); 225 clearTimeout(timeoutId); 226 // Process next flip in queue 227 processNextFlip(); 228 }; 229 leafToFlip.addEventListener('transitionend', cleanup); 230 231 // Fallback timeout in case transitionend doesn't fire 232 const timeoutId = setTimeout(cleanup, 800); 233 } 234 } 235 } 236 237 // Animate page flip with input buffering 238 export function animatePageFlip(fromSpread, toSpread, doc, onComplete) { 239 // If already animating, queue this flip request 240 if (isAnimating) { 241 // Check if there's already a queued flip to the same target - if so, skip this one 242 // This prevents the queue from growing unnecessarily when users spam the same direction 243 const lastQueued = pendingFlipQueue[pendingFlipQueue.length - 1]; 244 if (!lastQueued || lastQueued.toSpread !== toSpread) { 245 pendingFlipQueue.push({ fromSpread, toSpread, doc, onComplete }); 246 } 247 return; 248 } 249 250 // Start the flip immediately 251 executePageFlip(fromSpread, toSpread, doc, onComplete); 252 } 253 254 // Set current spread without animation (for initial load) 255 export function setSpreadImmediate(spreadIndex, doc) { 256 currentSpreadIndex = spreadIndex; 257 updateLeafStates(spreadIndex, doc); 258 updateBookPosition(spreadIndex, false); 259 }