scaling fixed; judder when opening/closing covers

Hunter
Wednesday, February 4th, 2026 at 2:56 PM

resources/page-flip-animation.js+35-12
resources/preview-manager.js+16-3
resources/spread-layout.js+8-2
resources/viewer-generator.js+54-3

resources/page-flip-animation.js
 73  // Update the book container horizontal position based on spread
 74  // Spreads 0 and 4 (single pages) should be centered, others at normal position
 75  function updateBookPosition(spreadIndex, animated = true) {
    -	if (!bookContainer) return;
 76 +	if (!bookContainer) {
 77 +		console.error('updateBookPosition: bookContainer is null!');
 78 +		return;
 79 +	}
 80  
 81  	// Spread 0 (front cover): single right page, shift left to center
 82  	// Spread 4 (back cover): single left page, shift right to center

 88  		shiftAmount = '1.375in';
 89  	}
 90  
 91 +	const currentTransform = bookContainer.style.transform;
 92 +	const currentTransition = bookContainer.style.transition;
 93 +	console.log(`updateBookPosition(spread=${spreadIndex}, animated=${animated})`);
 94 +	console.log(`  Current transform: "${currentTransform}"`);
 95 +	console.log(`  Current transition: "${currentTransition}"`);
 96 +	console.log(`  Target: translateX(${shiftAmount})`);
 97 +
 98  	if (animated) {
    -		bookContainer.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
    -	} else {
    -		bookContainer.style.transition = 'none';
    -	}
 99 +		// Clear any existing transition first to ensure clean state
100 +		bookContainer.style.transition = '';
101 +		// Force a reflow
102 +		bookContainer.offsetHeight;
103  
    -	// Preserve any existing scale from scaleSpreadToFit
    -	const currentTransform = bookContainer.style.transform || '';
    -	const scaleMatch = currentTransform.match(/scale\([^)]+\)/);
    -	const scale = scaleMatch ? scaleMatch[0] : '';
104 +		// Now set the transition for this animation
105 +		bookContainer.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
106 +		console.log(`  Set transition, will animate in rAF`);
107  
    -	bookContainer.style.transform = scale ? `${scale} translateX(${shiftAmount})` : `translateX(${shiftAmount})`;
108 +		// Use requestAnimationFrame to set transform (same as leaf flip animation)
109 +		// This ensures both animations start in the same frame
110 +		requestAnimationFrame(() => {
111 +			console.log(`  rAF: Setting transform to translateX(${shiftAmount})`);
112 +			bookContainer.style.transform = `translateX(${shiftAmount})`;
113 +		});
114  
    -	// Remove transition after animation completes
    -	if (animated) {
115 +		// Remove transition after animation completes
116  		setTimeout(() => {
117  			bookContainer.style.transition = '';
118  		}, 600);
119 +	} else {
120 +		bookContainer.style.transition = 'none';
121 +		// For non-animated, use explicit translateX or none
122 +		const newTransform = shiftAmount !== '0in' ? `translateX(${shiftAmount})` : 'translateX(0in)';
123 +		console.log(`  Non-animated: Setting transform to ${newTransform}`);
124 +		bookContainer.style.transform = newTransform;
125  	}
126  }
127  

170  	isAnimating = true;
171  	currentSpreadIndex = toSpread;
172  
173 +	console.log(`\n=== executePageFlip: ${fromSpread} → ${toSpread} ===`);
174 +
175  	// Update book position synchronously with the flip animation
176  	updateBookPosition(toSpread, true);
177  
178  	const direction = toSpread > fromSpread ? 'forward' : 'backward';
179 +	console.log(`Direction: ${direction}`);
180  	const leaves = doc.querySelectorAll('.zine-leaf');
181  
182  	if (direction === 'forward') {

273  
274  // Set current spread without animation (for initial load)
275  export function setSpreadImmediate(spreadIndex, doc) {
276 +	console.log(`\n=== setSpreadImmediate(${spreadIndex}) ===`);
277  	currentSpreadIndex = spreadIndex;
278  	updateLeafStates(spreadIndex, doc);
279  	updateBookPosition(spreadIndex, false);
resources/preview-manager.js
125  
126  					// Set initial spread state without animation
127  					setTimeout(() => {
128 +						scaleSpreadToFit(container, doc); // Must call this BEFORE setSpreadImmediate so zoom is set
129  						setSpreadImmediate(currentSpread, doc);
    -						const scaleToFit = () => scaleSpreadToFit(container, doc);
    -						scaleToFit();
    -						doc.defaultView.addEventListener('resize', scaleToFit);
130 +
131 +						// On resize, update zoom and reposition book
132 +						const handleResize = () => {
133 +							scaleSpreadToFit(container, doc);
134 +							// Get the actual current spread from the leaf states
135 +							const leaves = doc.querySelectorAll('.zine-leaf');
136 +							let activeSpread = 0;
137 +							leaves.forEach((leaf, index) => {
138 +								if (leaf.dataset.state === 'open') {
139 +									activeSpread = index + 1;
140 +								}
141 +							});
142 +							setSpreadImmediate(activeSpread, doc);
143 +						};
144 +						doc.defaultView.addEventListener('resize', handleResize);
145  					}, 0);
146  				} else {
147  					// Original non-animated mode
resources/spread-layout.js
 73  	const scaleY = (vh - 40) / spreadHeightPx;
 74  	const scale = Math.min(scaleX, scaleY);
 75  
 76 +	// Use CSS zoom for high-resolution rendering instead of transform scale
 77 +	bookContainer.style.zoom = scale;
 78 +
 79 +	// Store the current zoom on the book container for use by updateBookPosition
 80 +	bookContainer.dataset.currentZoom = scale;
 81 +
 82  	// Preserve any existing translateX from book positioning
 83  	const currentTransform = bookContainer.style.transform || '';
 84  	const translateMatch = currentTransform.match(/translateX\([^)]+\)/);
 85  	const translateX = translateMatch ? translateMatch[0] : '';
 86  
    -	bookContainer.style.transform = translateX ? `scale(${scale}) ${translateX}` : `scale(${scale})`;
 87 +	bookContainer.style.transform = translateX || '';
 88  
 89  	// Notify parent of spread position and grid size for background grid
 90  	if (doc.defaultView?.parent && doc.defaultView.parent !== doc.defaultView) {

 96  		const gridWidth = gridHeight * (30.25 / 29.75);
 97  
 98  		// Remove translateX shift so grid always anchors to the un-shifted book position
    -		// The translateX is in inches and gets multiplied by scale
 99 +		// With CSS zoom, translateX values are in the zoomed coordinate space
100  		const translateInchMatch = translateX.match(/translateX\(([^)]+)in\)/);
101  		const translatePx = translateInchMatch ? parseFloat(translateInchMatch[1]) * 96 * scale : 0;
102  
resources/viewer-generator.js
104  			const scaleX = (vw - 40) / spreadWidthPx;
105  			const scaleY = (vh - 40) / spreadHeightPx;
106  			const scale = Math.min(scaleX, scaleY);
    -			bookContainer.style.transform = 'scale(' + scale + ')';
107 +			bookContainer.style.zoom = scale;
108 +			bookContainer.dataset.currentZoom = scale;
109 +		}
110 +
111 +		function updateBookPosition(spreadIndex, animated) {
112 +			const bookContainer = container.querySelector('.zine-book');
113 +			if (!bookContainer) return;
114 +
115 +			// Spread 0 (front cover): single right page, shift left to center
116 +			// Spread 4 (back cover): single left page, shift right to center
117 +			// Half page width = 1.375in
118 +			let shiftAmount = '0in';
119 +			if (spreadIndex === 0) {
120 +				shiftAmount = '-1.375in';
121 +			} else if (spreadIndex === 4) {
122 +				shiftAmount = '1.375in';
123 +			}
124 +
125 +			if (animated) {
126 +				// Clear any existing transition first to ensure clean state
127 +				bookContainer.style.transition = '';
128 +				// Force a reflow
129 +				bookContainer.offsetHeight;
130 +
131 +				// Now set the transition for this animation
132 +				bookContainer.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
133 +
134 +				// Use requestAnimationFrame to set transform (same as leaf flip animation)
135 +				// This ensures both animations start in the same frame
136 +				requestAnimationFrame(function() {
137 +					bookContainer.style.transform = 'translateX(' + shiftAmount + ')';
138 +				});
139 +
140 +				// Remove transition after animation completes
141 +				setTimeout(function() {
142 +					bookContainer.style.transition = '';
143 +				}, 600);
144 +			} else {
145 +				bookContainer.style.transition = 'none';
146 +				// For non-animated, use explicit translateX
147 +				bookContainer.style.transform = shiftAmount !== '0in' ? 'translateX(' + shiftAmount + ')' : 'translateX(0in)';
148 +			}
149  		}
150  
151  		function initFlipMode() {

182  			if (isAnimating) return;
183  			isAnimating = true;
184  
185 +			// Update book position synchronously with the flip animation
186 +			updateBookPosition(toSpread, true);
187 +
188  			const direction = toSpread > fromSpread ? 'forward' : 'backward';
189  			const leaves = document.querySelectorAll('.zine-leaf');
190  

363  								leaf.style.zIndex = String(index + 1);
364  							}
365  						});
366 +						scaleToFit();
367 +						updateBookPosition(currentSpread, false);
368  					} else {
369  						showSpread(currentSpread, false);
370  					}
    -					scaleToFit();
371  
372  					const blob = new Blob([html], { type: 'text/html' });
373  					const url = URL.createObjectURL(blob);

379  				}
380  			});
381  
    -			window.addEventListener('resize', scaleToFit);
382 +			window.addEventListener('resize', function() {
383 +				scaleToFit();
384 +				if (USE_FLIP_ANIMATION) {
385 +					updateBookPosition(currentSpread, false);
386 +				}
387 +			});
388  
389  			// Initialize flip mode
390  			if (USE_FLIP_ANIMATION) {

393  				document.getElementById('zine-prev').disabled = true;
394  				document.getElementById('zine-next').disabled = false;
395  				scaleToFit();
396 +				updateBookPosition(0, false);
397  			} else {
398  				showSpread(0, false);
399  			}