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 }