add 3d page flip animation Hunter Monday, February 2nd, 2026 at 6:28 PM
21 // Message handler for iframe communication 22 window.addEventListener('message', function(event) { 23 if (event.data === 'toggleFullscreen') { - toggleFullscreen(updatePreviewWrapper); 24 + toggleFullscreen(); 25 + updatePreviewWrapper(); 26 } else if (event.data === 'prevSpread') { 27 navigateSpread(-1, updatePreviewWrapper); 28 } else if (event.data === 'nextSpread') { 105 // Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px) 106 window.addEventListener('resize', function() { 107 if (getIsFullscreen() && window.innerHeight <= 200) { - toggleFullscreen(updatePreviewWrapper); 108 + toggleFullscreen(); 109 + updatePreviewWrapper(); 110 } 111 }); 112 }
1 +// Page flip animation state 2 +let isAnimating = false; 3 +let currentSpreadIndex = 0; 4 + 5 +// Get animation state 6 +export function getIsAnimating() { 7 + return isAnimating; 8 +} 9 + 10 +// Page structure for flip animation 11 +// Each "leaf" has a front and back side 12 +// front-cover/page1 are on opposite sides of the same leaf 13 +// page2/page3 are on opposite sides of the next leaf, etc. 14 +const PAGE_LEAVES = [ 15 + { front: 'front-cover', back: 'page1' }, // Leaf 0 16 + { front: 'page2', back: 'page3' }, // Leaf 1 17 + { front: 'page4', back: 'page5' }, // Leaf 2 18 + { front: 'page6', back: 'back-cover' } // Leaf 3 19 +]; 20 + 21 +// Initialize the 3D page flip system 22 +export function initPageFlip(container, doc) { 23 + currentSpreadIndex = 0; 24 + 25 + // Create the book structure 26 + const book = doc.createElement('div'); 27 + book.className = 'zine-book'; 28 + 29 + // Create all leaves (page pairs) 30 + PAGE_LEAVES.forEach((leaf, index) => { 31 + const leafEl = doc.createElement('div'); 32 + leafEl.className = 'zine-leaf'; 33 + leafEl.dataset.leafIndex = index; 34 + leafEl.dataset.state = 'closed'; // closed, flipping, open 35 + 36 + // Front side of the leaf 37 + const frontSide = doc.createElement('div'); 38 + frontSide.className = 'zine-leaf-front'; 39 + const frontPage = doc.getElementById(leaf.front); 40 + if (frontPage) { 41 + frontSide.appendChild(frontPage.cloneNode(true)); 42 + } 43 + 44 + // Back side of the leaf 45 + const backSide = doc.createElement('div'); 46 + backSide.className = 'zine-leaf-back'; 47 + const backPage = doc.getElementById(leaf.back); 48 + if (backPage) { 49 + backSide.appendChild(backPage.cloneNode(true)); 50 + } 51 + 52 + leafEl.appendChild(frontSide); 53 + leafEl.appendChild(backSide); 54 + book.appendChild(leafEl); 55 + }); 56 + 57 + container.appendChild(book); 58 + 59 + // Hide original pages in body 60 + const originalPages = doc.querySelectorAll('.page:not(.zine-base-page)'); 61 + originalPages.forEach(page => { 62 + page.style.display = 'none'; 63 + }); 64 + 65 + // Set initial state - all leaves closed, showing front cover 66 + updateLeafStates(0, doc); 67 +} 68 + 69 +// Update which leaves are open/closed based on current spread 70 +function updateLeafStates(spreadIndex, doc) { 71 + const leaves = doc.querySelectorAll('.zine-leaf'); 72 + 73 + leaves.forEach((leaf, index) => { 74 + // Spread 0 = front cover (no leaves flipped) 75 + // Spread 1 = pages 1-2 (leaf 0 flipped) 76 + // Spread 2 = pages 3-4 (leaves 0-1 flipped) 77 + // Spread 3 = pages 5-6 (leaves 0-2 flipped) 78 + // Spread 4 = back cover (all leaves flipped) 79 + 80 + if (index < spreadIndex) { 81 + // This leaf should be flipped to the left (showing back) 82 + leaf.dataset.state = 'open'; 83 + leaf.style.transform = 'rotateY(-180deg)'; 84 + // Open leaves on left have lower z-index 85 + leaf.style.zIndex = String(index + 1); 86 + } else { 87 + // This leaf should be closed (on the right, showing front) 88 + leaf.dataset.state = 'closed'; 89 + leaf.style.transform = 'rotateY(0deg)'; 90 + // Closed leaves stack with first on top 91 + leaf.style.zIndex = String(20 - index); 92 + } 93 + }); 94 + 95 +} 96 + 97 +// Animate page flip 98 +export function animatePageFlip(fromSpread, toSpread, container, doc, onComplete) { 99 + if (isAnimating) return; 100 + 101 + isAnimating = true; 102 + currentSpreadIndex = toSpread; 103 + 104 + const direction = toSpread > fromSpread ? 'forward' : 'backward'; 105 + const leaves = doc.querySelectorAll('.zine-leaf'); 106 + 107 + if (direction === 'forward') { 108 + // Flipping forward (right to left) 109 + const leafToFlip = leaves[fromSpread]; 110 + if (leafToFlip) { 111 + leafToFlip.dataset.state = 'flipping'; 112 + leafToFlip.style.zIndex = '100'; // Put it on top during flip 113 + 114 + // Enable transition 115 + leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)'; 116 + 117 + // Trigger the flip 118 + requestAnimationFrame(() => { 119 + leafToFlip.style.transform = 'rotateY(-180deg)'; 120 + }); 121 + 122 + // Clean up after animation 123 + const cleanup = () => { 124 + leafToFlip.dataset.state = 'open'; 125 + leafToFlip.style.transition = ''; 126 + // Set z-index for open state (lower than closed leaves) 127 + leafToFlip.style.zIndex = String(fromSpread + 1); 128 + isAnimating = false; 129 + if (onComplete) onComplete(); 130 + leafToFlip.removeEventListener('transitionend', cleanup); 131 + }; 132 + leafToFlip.addEventListener('transitionend', cleanup); 133 + } 134 + } else { 135 + // Flipping backward (left to right) 136 + const leafToFlip = leaves[toSpread]; 137 + if (leafToFlip) { 138 + leafToFlip.dataset.state = 'flipping'; 139 + leafToFlip.style.zIndex = '100'; // Put it on top during flip 140 + 141 + // Enable transition 142 + leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)'; 143 + 144 + // Trigger the flip 145 + requestAnimationFrame(() => { 146 + leafToFlip.style.transform = 'rotateY(0deg)'; 147 + }); 148 + 149 + // Clean up after animation 150 + const cleanup = () => { 151 + leafToFlip.dataset.state = 'closed'; 152 + leafToFlip.style.transition = ''; 153 + // Set z-index for closed state (higher than open leaves) 154 + leafToFlip.style.zIndex = String(20 - toSpread); 155 + isAnimating = false; 156 + if (onComplete) onComplete(); 157 + leafToFlip.removeEventListener('transitionend', cleanup); 158 + }; 159 + leafToFlip.addEventListener('transitionend', cleanup); 160 + } 161 + } 162 +} 163 + 164 +// Set current spread without animation (for initial load) 165 +export function setSpreadImmediate(spreadIndex, doc) { 166 + currentSpreadIndex = spreadIndex; 167 + updateLeafStates(spreadIndex, doc); 168 +}
1 import { insertAfterHead } from './html-utils.js'; 2 import { extractTitleAndFavicon, updateMainPageTitleAndFavicon } from './html-utils.js'; -import { generateSpreadCSS } from './zine-styles.js'; 3 +import { generateSpreadCSS, ZINE_PAGE_CSS, ZINE_FLIP_CSS, ZINE_PRINT_CSS } from './zine-styles.js'; 4 import { SPREADS } from './constants.js'; 5 import { getCurrentSpread } from './spread-navigation.js'; 6 import { isMobileDevice } from './mobile-keyboard.js'; -import { setupSpreadLayout, scaleSpreadToFit } from './spread-layout.js'; 7 +import { setupSpreadLayout, scaleSpreadToFit, navigateToSpread } from './spread-layout.js'; 8 +import { setSpreadImmediate } from './page-flip-animation.js'; 9 + 10 +// Track if flip mode is enabled 11 +let flipModeEnabled = true; // Default to enabled 12 +let previousSpread = 0; 13 + 14 +export function setFlipMode(enabled) { 15 + flipModeEnabled = enabled; 16 +} 17 + 18 +export function isFlipModeEnabled() { 19 + return flipModeEnabled; 20 +} 21 + 22 +// Generate CSS for flip mode 23 +function generateFlipModeCSS() { 24 + return ` 25 + * { margin: 0; padding: 0; box-sizing: border-box; } 26 + 27 + @media screen { 28 + html, body { 29 + height: 100% !important; 30 + overflow: hidden !important; 31 + } 32 + 33 + body { 34 + display: flex !important; 35 + justify-content: center !important; 36 + align-items: center !important; 37 + } 38 + 39 + body > *:not(.zine-spread-container):not(.iframe-fullscreen-toggle) { 40 + display: none !important; 41 + } 42 + 43 + ${ZINE_PAGE_CSS} 44 + ${ZINE_FLIP_CSS} 45 + 46 + .zine-spread-container { 47 + display: flex !important; 48 + position: relative; 49 + } 50 + 51 + .page { 52 + display: block !important; 53 + } 54 + } 55 + 56 + @media print { 57 + ${ZINE_PRINT_CSS} 58 + } 59 + `; 60 +} 61 62 export function updatePreview(editorView, isEditorFocused) { 63 const preview = document.getElementById('preview'); 82 83 // Inject spread CSS right after <head> so user styles come last and take precedence 84 const currentSpread = getCurrentSpread(); - const spreadCSS = generateSpreadCSS(currentSpread); 85 + const spreadCSS = flipModeEnabled ? generateFlipModeCSS() : generateSpreadCSS(currentSpread); 86 const processedCode = insertAfterHead(code, `<style id="zine-editor-spread-css">${spreadCSS}</style>`); 87 88 preview.srcdoc = processedCode || '<!DOCTYPE html><html><head></head><body></body></html>'; 118 const container = doc.createElement('div'); 119 container.className = 'zine-spread-container'; 120 - setupSpreadLayout(container, spread, doc); - doc.body.appendChild(container); 121 + if (flipModeEnabled) { 122 + // Setup flip animation mode 123 + setupSpreadLayout(container, spread, doc, true); 124 + doc.body.appendChild(container); 125 + 126 + // Set initial spread state without animation 127 + setTimeout(() => { 128 + setSpreadImmediate(currentSpread, doc); 129 + const scaleToFit = () => scaleSpreadToFit(container, doc); 130 + scaleToFit(); 131 + doc.defaultView.addEventListener('resize', scaleToFit); 132 + }, 0); 133 + } else { 134 + // Original non-animated mode 135 + setupSpreadLayout(container, spread, doc, false); 136 + doc.body.appendChild(container); 137 + 138 + // Scale container to fit viewport 139 + const scaleToFit = () => scaleSpreadToFit(container, doc); 140 + scaleToFit(); 141 + doc.defaultView.addEventListener('resize', scaleToFit); 142 + } 143 - // Scale container to fit viewport - const scaleToFit = () => scaleSpreadToFit(container, doc); - scaleToFit(); - doc.defaultView.addEventListener('resize', scaleToFit); 144 + // Store container and flip mode state for navigation updates 145 + doc._zineContainer = container; 146 + doc._flipModeEnabled = flipModeEnabled; 147 } 148 149 // Add keyboard listener for navigation and shortcuts
1 import { SPREADS } from './constants.js'; 2 import { createEmptyPlaceholder } from './html-utils.js'; 3 +import { initPageFlip, animatePageFlip, setSpreadImmediate, getIsAnimating } from './page-flip-animation.js'; 4 + 5 +// Track whether flip mode is enabled 6 +let flipModeEnabled = false; 7 +let currentFlipSpread = 0; 8 + 9 +// Enable/disable flip animation mode 10 +export function setFlipMode(enabled) { 11 + flipModeEnabled = enabled; 12 +} 13 + 14 +export function isFlipModeEnabled() { 15 + return flipModeEnabled; 16 +} 17 18 // Common logic for displaying a spread (used by both editor preview and standalone viewer) -export function setupSpreadLayout(container, spread, doc) { 19 +export function setupSpreadLayout(container, spread, doc, useFlipAnimation = false) { 20 + if (useFlipAnimation) { 21 + // Initialize flip animation on first call 22 + if (!container.querySelector('.zine-book')) { 23 + initPageFlip(container, doc); 24 + } 25 + return; 26 + } 27 + 28 + // Original non-animated behavior 29 // Add empty placeholder before right page if no left page 30 if (!spread.left && spread.right) { 31 container.appendChild(createEmptyPlaceholder(doc)); 44 } 45 } 46 47 +// Navigate to a spread with optional flip animation 48 +export function navigateToSpread(container, spreadIndex, doc, useFlipAnimation = false, onComplete) { 49 + if (useFlipAnimation && container.querySelector('.zine-book')) { 50 + if (!getIsAnimating()) { 51 + animatePageFlip(currentFlipSpread, spreadIndex, container, doc, onComplete); 52 + currentFlipSpread = spreadIndex; 53 + } 54 + } else { 55 + // Original non-animated behavior 56 + const spread = SPREADS[spreadIndex]; 57 + container.innerHTML = ''; 58 + setupSpreadLayout(container, spread, doc, false); 59 + if (onComplete) onComplete(); 60 + } 61 +} 62 + 63 // Scale spread container to fit viewport 64 export function scaleSpreadToFit(container, doc, bottomPadding = 0) { 65 + // Find the book container if in flip mode 66 + const bookContainer = container.querySelector('.zine-book') || container; 67 + 68 const vw = doc.documentElement.clientWidth; 69 const vh = doc.documentElement.clientHeight - bottomPadding; - const spreadWidthPx = container.offsetWidth; - const spreadHeightPx = container.offsetHeight; 70 + const spreadWidthPx = bookContainer.offsetWidth; 71 + const spreadHeightPx = bookContainer.offsetHeight; 72 if (spreadWidthPx === 0 || spreadHeightPx === 0) return; 73 const scaleX = (vw - 40) / spreadWidthPx; 74 const scaleY = (vh - 40) / spreadHeightPx; 75 const scale = Math.min(scaleX, scaleY); - container.style.transform = `scale(${scale})`; 76 + bookContainer.style.transform = `scale(${scale})`; 77 }
41 if (newSpread >= 0 && newSpread < SPREADS.length) { 42 currentSpread = newSpread; 43 updateSpreadIndicator(); - updatePreviewCallback(); - // Refocus preview so keyboard navigation continues to work - document.getElementById('preview').focus(); 44 + 45 + // Try to animate if flip mode is enabled 46 + const preview = document.getElementById('preview'); 47 + try { 48 + const doc = preview.contentDocument; 49 + const container = doc?._zineContainer; 50 + const flipModeEnabled = doc?._flipModeEnabled; 51 + 52 + if (container && flipModeEnabled) { 53 + // Dynamic import to avoid circular dependency 54 + import('./spread-layout.js').then(({ navigateToSpread }) => { 55 + navigateToSpread(container, newSpread, doc, true, () => { 56 + // Refocus preview so keyboard navigation continues to work 57 + preview.focus(); 58 + }); 59 + }); 60 + } else { 61 + updatePreviewCallback(); 62 + preview.focus(); 63 + } 64 + } catch (e) { 65 + // Fallback to regular update 66 + updatePreviewCallback(); 67 + preview.focus(); 68 + } 69 } 70 } 71 72 // Fullscreen toggle -export function toggleFullscreen(updatePreviewCallback) { 73 +export function toggleFullscreen() { 74 isFullscreen = !isFullscreen; 75 const editorPane = document.querySelector('.editor-pane'); 76 const previewPane = document.querySelector('.preview-pane');
1 import { PAGE_IDS, SPREADS, NAV_HEIGHT, NAV_BUTTON_CSS, NAV_BUTTON_STYLES } from './constants.js'; -import { ZINE_PAGE_CSS, ZINE_PRINT_CSS } from './zine-styles.js'; 2 +import { ZINE_PAGE_CSS, ZINE_PRINT_CSS, ZINE_FLIP_CSS } from './zine-styles.js'; 3 4 // Generate standalone viewer CSS and JS (matches preview pane rendering) 5 export function generateViewerCode() { 22 display: none !important; 23 } 24 ${ZINE_PAGE_CSS} 25 + ${ZINE_FLIP_CSS} 26 + 27 + /* Dynamic z-index based on leaf state */ 28 + .zine-leaf[data-state="closed"][data-leaf-index="0"] { z-index: 14 !important; } 29 + .zine-leaf[data-state="closed"][data-leaf-index="1"] { z-index: 13 !important; } 30 + .zine-leaf[data-state="closed"][data-leaf-index="2"] { z-index: 12 !important; } 31 + .zine-leaf[data-state="closed"][data-leaf-index="3"] { z-index: 11 !important; } 32 + .zine-leaf[data-state="open"][data-leaf-index="0"] { z-index: 4 !important; } 33 + .zine-leaf[data-state="open"][data-leaf-index="1"] { z-index: 3 !important; } 34 + .zine-leaf[data-state="open"][data-leaf-index="2"] { z-index: 2 !important; } 35 + .zine-leaf[data-state="open"][data-leaf-index="3"] { z-index: 1 !important; } 36 + .zine-leaf[data-state="flipping"] { z-index: 20 !important; } 37 + 38 .zine-spread-container { 39 display: flex !important; 40 position: relative; 78 const NAV_HEIGHT = ${NAV_HEIGHT}; 79 let currentSpread = 0; 80 let container; 81 + let isAnimating = false; 82 + const USE_FLIP_ANIMATION = true; 83 + 84 + // Page leaf structure (front and back of each physical page) 85 + const PAGE_LEAVES = [ 86 + { front: 'front-cover', back: 'page1' }, 87 + { front: 'page2', back: 'page3' }, 88 + { front: 'page4', back: 'page5' }, 89 + { front: 'page6', back: 'back-cover' } 90 + ]; 91 + 92 + function easeInOutCubic(t) { 93 + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 94 + } 95 96 function scaleToFit() { 97 if (!container) return; 98 + const bookContainer = container.querySelector('.zine-book') || container; 99 const vw = document.documentElement.clientWidth; 100 const vh = document.documentElement.clientHeight - NAV_HEIGHT; - const spreadWidthPx = container.offsetWidth; - const spreadHeightPx = container.offsetHeight; 101 + const spreadWidthPx = bookContainer.offsetWidth; 102 + const spreadHeightPx = bookContainer.offsetHeight; 103 if (spreadWidthPx === 0 || spreadHeightPx === 0) return; 104 const scaleX = (vw - 40) / spreadWidthPx; 105 const scaleY = (vh - 40) / spreadHeightPx; 106 const scale = Math.min(scaleX, scaleY); - container.style.transform = 'scale(' + scale + ')'; 107 + bookContainer.style.transform = 'scale(' + scale + ')'; 108 + } 109 + 110 + function initFlipMode() { 111 + const book = document.createElement('div'); 112 + book.className = 'zine-book'; 113 + 114 + // Create all leaves 115 + PAGE_LEAVES.forEach((leaf, index) => { 116 + const leafEl = document.createElement('div'); 117 + leafEl.className = 'zine-leaf'; 118 + leafEl.dataset.leafIndex = index; 119 + leafEl.dataset.state = 'closed'; 120 + leafEl.style.zIndex = String(20 - index); // Initial z-index for closed state 121 + 122 + const frontSide = document.createElement('div'); 123 + frontSide.className = 'zine-leaf-front'; 124 + const frontPage = document.getElementById(leaf.front); 125 + if (frontPage) frontSide.appendChild(frontPage.cloneNode(true)); 126 + 127 + const backSide = document.createElement('div'); 128 + backSide.className = 'zine-leaf-back'; 129 + const backPage = document.getElementById(leaf.back); 130 + if (backPage) backSide.appendChild(backPage.cloneNode(true)); 131 + 132 + leafEl.appendChild(frontSide); 133 + leafEl.appendChild(backSide); 134 + book.appendChild(leafEl); 135 + }); 136 + 137 + container.appendChild(book); 138 } 139 - function showSpread(index) { 140 + function animateFlip(fromSpread, toSpread, onComplete) { 141 + if (isAnimating) return; 142 + isAnimating = true; 143 + 144 + const direction = toSpread > fromSpread ? 'forward' : 'backward'; 145 + const leaves = document.querySelectorAll('.zine-leaf'); 146 + 147 + if (direction === 'forward') { 148 + const leafToFlip = leaves[fromSpread]; 149 + if (leafToFlip) { 150 + leafToFlip.dataset.state = 'flipping'; 151 + leafToFlip.style.zIndex = '100'; 152 + const startTime = performance.now(); 153 + const duration = 600; 154 + 155 + function animate(currentTime) { 156 + const elapsed = currentTime - startTime; 157 + const progress = Math.min(elapsed / duration, 1); 158 + const eased = easeInOutCubic(progress); 159 + const rotation = eased * -180; 160 + leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)'; 161 + 162 + if (progress < 1) { 163 + requestAnimationFrame(animate); 164 + } else { 165 + leafToFlip.dataset.state = 'open'; 166 + leafToFlip.style.zIndex = String(fromSpread + 1); 167 + isAnimating = false; 168 + if (onComplete) onComplete(); 169 + } 170 + } 171 + requestAnimationFrame(animate); 172 + } 173 + } else { 174 + const leafToFlip = leaves[toSpread]; 175 + if (leafToFlip) { 176 + leafToFlip.dataset.state = 'flipping'; 177 + leafToFlip.style.zIndex = '100'; 178 + const startTime = performance.now(); 179 + const duration = 600; 180 + 181 + function animate(currentTime) { 182 + const elapsed = currentTime - startTime; 183 + const progress = Math.min(elapsed / duration, 1); 184 + const eased = easeInOutCubic(progress); 185 + const rotation = -180 + (eased * 180); 186 + leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)'; 187 + 188 + if (progress < 1) { 189 + requestAnimationFrame(animate); 190 + } else { 191 + leafToFlip.dataset.state = 'closed'; 192 + leafToFlip.style.zIndex = String(20 - toSpread); 193 + isAnimating = false; 194 + if (onComplete) onComplete(); 195 + } 196 + } 197 + requestAnimationFrame(animate); 198 + } 199 + } 200 + } 201 + 202 + function showSpread(index, animated) { 203 const spread = SPREADS[index]; - const visible = [spread.left, spread.right].filter(Boolean); 204 - // Move existing pages back to body before clearing container 205 + if (USE_FLIP_ANIMATION && animated && container.querySelector('.zine-book')) { 206 + animateFlip(currentSpread, index, () => { 207 + document.getElementById('zine-indicator').textContent = spread.label; 208 + document.getElementById('zine-prev').disabled = index === 0; 209 + document.getElementById('zine-next').disabled = index === SPREADS.length - 1; 210 + }); 211 + return; 212 + } 213 + 214 + // Non-animated or initial setup 215 + const visible = [spread.left, spread.right].filter(Boolean); 216 PAGE_IDS.forEach(id => { 217 const page = document.getElementById(id); 218 if (page) { 252 function navigate(delta) { 253 const newIndex = currentSpread + delta; 254 if (newIndex >= 0 && newIndex < SPREADS.length) { 255 + const oldSpread = currentSpread; 256 currentSpread = newIndex; - showSpread(currentSpread); 257 + showSpread(currentSpread, true); 258 } 259 } 260 309 document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); 310 document.getElementById('zine-next').addEventListener('click', () => navigate(1)); 311 - showSpread(currentSpread); 312 + if (USE_FLIP_ANIMATION) { 313 + initFlipMode(); 314 + const leaves = document.querySelectorAll('.zine-leaf'); 315 + leaves.forEach((leaf, index) => { 316 + if (index < currentSpread) { 317 + leaf.dataset.state = 'open'; 318 + leaf.style.transform = 'rotateY(-180deg)'; 319 + leaf.style.zIndex = String(index + 1); 320 + } 321 + }); 322 + } else { 323 + showSpread(currentSpread, false); 324 + } 325 + scaleToFit(); 326 327 const blob = new Blob([html], { type: 'text/html' }); 328 const url = URL.createObjectURL(blob); 336 337 window.addEventListener('resize', scaleToFit); 338 - showSpread(0); 339 + // Initialize flip mode 340 + if (USE_FLIP_ANIMATION) { 341 + initFlipMode(); 342 + document.getElementById('zine-indicator').textContent = SPREADS[0].label; 343 + document.getElementById('zine-prev').disabled = true; 344 + document.getElementById('zine-next').disabled = false; 345 + scaleToFit(); 346 + } else { 347 + showSpread(0, false); 348 + } 349 }); 350 `; 351
41 a { color: black; } 42 `; 43 44 +// CSS for 3D page flip animation 45 +export const ZINE_FLIP_CSS = ` 46 + .zine-book { 47 + position: relative; 48 + width: ${PAGE_WIDTH_IN * 2}in; 49 + height: ${PAGE_HEIGHT_IN}in; 50 + perspective: 2000px; 51 + transform-style: preserve-3d; 52 + transform-origin: center center; 53 + } 54 + 55 + .zine-leaf { 56 + position: absolute; 57 + width: ${PAGE_WIDTH_IN}in; 58 + height: ${PAGE_HEIGHT_IN}in; 59 + left: ${PAGE_WIDTH_IN}in; 60 + top: 0; 61 + transform-origin: left center; 62 + transform-style: preserve-3d; 63 + transition: none; 64 + } 65 + 66 + /* When flipping, use high z-index so it's on top during animation */ 67 + .zine-leaf[data-state="flipping"] { 68 + z-index: 20 !important; 69 + transition: none; 70 + } 71 + 72 + .zine-leaf-front, 73 + .zine-leaf-back { 74 + position: absolute; 75 + width: 100%; 76 + height: 100%; 77 + backface-visibility: hidden; 78 + -webkit-backface-visibility: hidden; 79 + } 80 + 81 + .zine-leaf-front { 82 + transform: rotateY(0deg); 83 + z-index: 2; 84 + } 85 + 86 + .zine-leaf-back { 87 + transform: rotateY(180deg); 88 + z-index: 1; 89 + } 90 + 91 + .zine-leaf-front .page, 92 + .zine-leaf-back .page { 93 + width: 100%; 94 + height: 100%; 95 + display: block !important; 96 + } 97 + 98 + /* Base pages - shown beneath the flipping leaves */ 99 + .zine-base-pages { 100 + position: absolute; 101 + width: 100%; 102 + height: 100%; 103 + display: flex; 104 + justify-content: center; 105 + align-items: center; 106 + z-index: 0; 107 + } 108 + 109 + .zine-base-page { 110 + position: absolute; 111 + width: ${PAGE_WIDTH_IN}in; 112 + height: ${PAGE_HEIGHT_IN}in; 113 + display: none !important; 114 + z-index: 0; 115 + } 116 + 117 + /* Position base pages */ 118 + .zine-base-page[data-page-id="front-cover"], 119 + .zine-base-page[data-page-id="page2"], 120 + .zine-base-page[data-page-id="page4"], 121 + .zine-base-page[data-page-id="page6"] { 122 + right: 0; 123 + } 124 + 125 + .zine-base-page[data-page-id="page1"], 126 + .zine-base-page[data-page-id="page3"], 127 + .zine-base-page[data-page-id="page5"], 128 + .zine-base-page[data-page-id="back-cover"] { 129 + left: 0; 130 + } 131 +`; 132 + 133 // Shared CSS for zine pages (used by both editor preview and exported viewer) 134 export const ZINE_PAGE_CSS = ` 135 ${ALL_PAGES_SELECTOR} {