commit e85bdcd0cc37a32978a1a5da2ee0721b9af3e846
parent 37eb49448871d2fcacbf623e5cfa2103180f83bf
Author: Hunter
Date: Mon, 2 Feb 2026 18:28:14 -0500
add 3d page flip animation
Diffstat:
7 files changed, 576 insertions(+), 29 deletions(-)
diff --git a/resources/editor.js b/resources/editor.js
@@ -21,7 +21,8 @@ function updatePreviewFromContent() {
// Message handler for iframe communication
window.addEventListener('message', function(event) {
if (event.data === 'toggleFullscreen') {
- toggleFullscreen(updatePreviewWrapper);
+ toggleFullscreen();
+ updatePreviewWrapper();
} else if (event.data === 'prevSpread') {
navigateSpread(-1, updatePreviewWrapper);
} else if (event.data === 'nextSpread') {
@@ -104,7 +105,8 @@ async function initializeEditor() {
// Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px)
window.addEventListener('resize', function() {
if (getIsFullscreen() && window.innerHeight <= 200) {
- toggleFullscreen(updatePreviewWrapper);
+ toggleFullscreen();
+ updatePreviewWrapper();
}
});
}
diff --git a/resources/page-flip-animation.js b/resources/page-flip-animation.js
@@ -0,0 +1,168 @@
+// Page flip animation state
+let isAnimating = false;
+let currentSpreadIndex = 0;
+
+// Get animation state
+export function getIsAnimating() {
+ return isAnimating;
+}
+
+// Page structure for flip animation
+// Each "leaf" has a front and back side
+// front-cover/page1 are on opposite sides of the same leaf
+// page2/page3 are on opposite sides of the next leaf, etc.
+const PAGE_LEAVES = [
+ { front: 'front-cover', back: 'page1' }, // Leaf 0
+ { front: 'page2', back: 'page3' }, // Leaf 1
+ { front: 'page4', back: 'page5' }, // Leaf 2
+ { front: 'page6', back: 'back-cover' } // Leaf 3
+];
+
+// Initialize the 3D page flip system
+export function initPageFlip(container, doc) {
+ currentSpreadIndex = 0;
+
+ // Create the book structure
+ const book = doc.createElement('div');
+ book.className = 'zine-book';
+
+ // Create all leaves (page pairs)
+ PAGE_LEAVES.forEach((leaf, index) => {
+ const leafEl = doc.createElement('div');
+ leafEl.className = 'zine-leaf';
+ leafEl.dataset.leafIndex = index;
+ leafEl.dataset.state = 'closed'; // closed, flipping, open
+
+ // Front side of the leaf
+ const frontSide = doc.createElement('div');
+ frontSide.className = 'zine-leaf-front';
+ const frontPage = doc.getElementById(leaf.front);
+ if (frontPage) {
+ frontSide.appendChild(frontPage.cloneNode(true));
+ }
+
+ // Back side of the leaf
+ const backSide = doc.createElement('div');
+ backSide.className = 'zine-leaf-back';
+ const backPage = doc.getElementById(leaf.back);
+ if (backPage) {
+ backSide.appendChild(backPage.cloneNode(true));
+ }
+
+ leafEl.appendChild(frontSide);
+ leafEl.appendChild(backSide);
+ book.appendChild(leafEl);
+ });
+
+ container.appendChild(book);
+
+ // Hide original pages in body
+ const originalPages = doc.querySelectorAll('.page:not(.zine-base-page)');
+ originalPages.forEach(page => {
+ page.style.display = 'none';
+ });
+
+ // Set initial state - all leaves closed, showing front cover
+ updateLeafStates(0, doc);
+}
+
+// Update which leaves are open/closed based on current spread
+function updateLeafStates(spreadIndex, doc) {
+ const leaves = doc.querySelectorAll('.zine-leaf');
+
+ leaves.forEach((leaf, index) => {
+ // Spread 0 = front cover (no leaves flipped)
+ // Spread 1 = pages 1-2 (leaf 0 flipped)
+ // Spread 2 = pages 3-4 (leaves 0-1 flipped)
+ // Spread 3 = pages 5-6 (leaves 0-2 flipped)
+ // Spread 4 = back cover (all leaves flipped)
+
+ if (index < spreadIndex) {
+ // This leaf should be flipped to the left (showing back)
+ leaf.dataset.state = 'open';
+ leaf.style.transform = 'rotateY(-180deg)';
+ // Open leaves on left have lower z-index
+ leaf.style.zIndex = String(index + 1);
+ } else {
+ // This leaf should be closed (on the right, showing front)
+ leaf.dataset.state = 'closed';
+ leaf.style.transform = 'rotateY(0deg)';
+ // Closed leaves stack with first on top
+ leaf.style.zIndex = String(20 - index);
+ }
+ });
+
+}
+
+// Animate page flip
+export function animatePageFlip(fromSpread, toSpread, container, doc, onComplete) {
+ if (isAnimating) return;
+
+ isAnimating = true;
+ currentSpreadIndex = toSpread;
+
+ const direction = toSpread > fromSpread ? 'forward' : 'backward';
+ const leaves = doc.querySelectorAll('.zine-leaf');
+
+ if (direction === 'forward') {
+ // Flipping forward (right to left)
+ const leafToFlip = leaves[fromSpread];
+ if (leafToFlip) {
+ leafToFlip.dataset.state = 'flipping';
+ leafToFlip.style.zIndex = '100'; // Put it on top during flip
+
+ // Enable transition
+ leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
+
+ // Trigger the flip
+ requestAnimationFrame(() => {
+ leafToFlip.style.transform = 'rotateY(-180deg)';
+ });
+
+ // Clean up after animation
+ const cleanup = () => {
+ leafToFlip.dataset.state = 'open';
+ leafToFlip.style.transition = '';
+ // Set z-index for open state (lower than closed leaves)
+ leafToFlip.style.zIndex = String(fromSpread + 1);
+ isAnimating = false;
+ if (onComplete) onComplete();
+ leafToFlip.removeEventListener('transitionend', cleanup);
+ };
+ leafToFlip.addEventListener('transitionend', cleanup);
+ }
+ } else {
+ // Flipping backward (left to right)
+ const leafToFlip = leaves[toSpread];
+ if (leafToFlip) {
+ leafToFlip.dataset.state = 'flipping';
+ leafToFlip.style.zIndex = '100'; // Put it on top during flip
+
+ // Enable transition
+ leafToFlip.style.transition = 'transform 0.6s cubic-bezier(0.645, 0.045, 0.355, 1.000)';
+
+ // Trigger the flip
+ requestAnimationFrame(() => {
+ leafToFlip.style.transform = 'rotateY(0deg)';
+ });
+
+ // Clean up after animation
+ const cleanup = () => {
+ leafToFlip.dataset.state = 'closed';
+ leafToFlip.style.transition = '';
+ // Set z-index for closed state (higher than open leaves)
+ leafToFlip.style.zIndex = String(20 - toSpread);
+ isAnimating = false;
+ if (onComplete) onComplete();
+ leafToFlip.removeEventListener('transitionend', cleanup);
+ };
+ leafToFlip.addEventListener('transitionend', cleanup);
+ }
+ }
+}
+
+// Set current spread without animation (for initial load)
+export function setSpreadImmediate(spreadIndex, doc) {
+ currentSpreadIndex = spreadIndex;
+ updateLeafStates(spreadIndex, doc);
+}
diff --git a/resources/preview-manager.js b/resources/preview-manager.js
@@ -1,10 +1,63 @@
import { insertAfterHead } from './html-utils.js';
import { extractTitleAndFavicon, updateMainPageTitleAndFavicon } from './html-utils.js';
-import { generateSpreadCSS } from './zine-styles.js';
+import { generateSpreadCSS, ZINE_PAGE_CSS, ZINE_FLIP_CSS, ZINE_PRINT_CSS } from './zine-styles.js';
import { SPREADS } from './constants.js';
import { getCurrentSpread } from './spread-navigation.js';
import { isMobileDevice } from './mobile-keyboard.js';
-import { setupSpreadLayout, scaleSpreadToFit } from './spread-layout.js';
+import { setupSpreadLayout, scaleSpreadToFit, navigateToSpread } from './spread-layout.js';
+import { setSpreadImmediate } from './page-flip-animation.js';
+
+// Track if flip mode is enabled
+let flipModeEnabled = true; // Default to enabled
+let previousSpread = 0;
+
+export function setFlipMode(enabled) {
+ flipModeEnabled = enabled;
+}
+
+export function isFlipModeEnabled() {
+ return flipModeEnabled;
+}
+
+// Generate CSS for flip mode
+function generateFlipModeCSS() {
+ return `
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+
+ @media screen {
+ html, body {
+ height: 100% !important;
+ overflow: hidden !important;
+ }
+
+ body {
+ display: flex !important;
+ justify-content: center !important;
+ align-items: center !important;
+ }
+
+ body > *:not(.zine-spread-container):not(.iframe-fullscreen-toggle) {
+ display: none !important;
+ }
+
+ ${ZINE_PAGE_CSS}
+ ${ZINE_FLIP_CSS}
+
+ .zine-spread-container {
+ display: flex !important;
+ position: relative;
+ }
+
+ .page {
+ display: block !important;
+ }
+ }
+
+ @media print {
+ ${ZINE_PRINT_CSS}
+ }
+ `;
+}
export function updatePreview(editorView, isEditorFocused) {
const preview = document.getElementById('preview');
@@ -29,7 +82,7 @@ export function updatePreview(editorView, isEditorFocused) {
// Inject spread CSS right after <head> so user styles come last and take precedence
const currentSpread = getCurrentSpread();
- const spreadCSS = generateSpreadCSS(currentSpread);
+ const spreadCSS = flipModeEnabled ? generateFlipModeCSS() : generateSpreadCSS(currentSpread);
const processedCode = insertAfterHead(code, `<style id="zine-editor-spread-css">${spreadCSS}</style>`);
preview.srcdoc = processedCode || '<!DOCTYPE html><html><head></head><body></body></html>';
@@ -65,13 +118,32 @@ export function updatePreview(editorView, isEditorFocused) {
const container = doc.createElement('div');
container.className = 'zine-spread-container';
- setupSpreadLayout(container, spread, doc);
- doc.body.appendChild(container);
+ if (flipModeEnabled) {
+ // Setup flip animation mode
+ setupSpreadLayout(container, spread, doc, true);
+ doc.body.appendChild(container);
+
+ // Set initial spread state without animation
+ setTimeout(() => {
+ setSpreadImmediate(currentSpread, doc);
+ const scaleToFit = () => scaleSpreadToFit(container, doc);
+ scaleToFit();
+ doc.defaultView.addEventListener('resize', scaleToFit);
+ }, 0);
+ } else {
+ // Original non-animated mode
+ setupSpreadLayout(container, spread, doc, false);
+ doc.body.appendChild(container);
+
+ // Scale container to fit viewport
+ const scaleToFit = () => scaleSpreadToFit(container, doc);
+ scaleToFit();
+ doc.defaultView.addEventListener('resize', scaleToFit);
+ }
- // Scale container to fit viewport
- const scaleToFit = () => scaleSpreadToFit(container, doc);
- scaleToFit();
- doc.defaultView.addEventListener('resize', scaleToFit);
+ // Store container and flip mode state for navigation updates
+ doc._zineContainer = container;
+ doc._flipModeEnabled = flipModeEnabled;
}
// Add keyboard listener for navigation and shortcuts
diff --git a/resources/spread-layout.js b/resources/spread-layout.js
@@ -1,8 +1,31 @@
import { SPREADS } from './constants.js';
import { createEmptyPlaceholder } from './html-utils.js';
+import { initPageFlip, animatePageFlip, setSpreadImmediate, getIsAnimating } from './page-flip-animation.js';
+
+// Track whether flip mode is enabled
+let flipModeEnabled = false;
+let currentFlipSpread = 0;
+
+// Enable/disable flip animation mode
+export function setFlipMode(enabled) {
+ flipModeEnabled = enabled;
+}
+
+export function isFlipModeEnabled() {
+ return flipModeEnabled;
+}
// Common logic for displaying a spread (used by both editor preview and standalone viewer)
-export function setupSpreadLayout(container, spread, doc) {
+export function setupSpreadLayout(container, spread, doc, useFlipAnimation = false) {
+ if (useFlipAnimation) {
+ // Initialize flip animation on first call
+ if (!container.querySelector('.zine-book')) {
+ initPageFlip(container, doc);
+ }
+ return;
+ }
+
+ // Original non-animated behavior
// Add empty placeholder before right page if no left page
if (!spread.left && spread.right) {
container.appendChild(createEmptyPlaceholder(doc));
@@ -21,15 +44,34 @@ export function setupSpreadLayout(container, spread, doc) {
}
}
+// Navigate to a spread with optional flip animation
+export function navigateToSpread(container, spreadIndex, doc, useFlipAnimation = false, onComplete) {
+ if (useFlipAnimation && container.querySelector('.zine-book')) {
+ if (!getIsAnimating()) {
+ animatePageFlip(currentFlipSpread, spreadIndex, container, doc, onComplete);
+ currentFlipSpread = spreadIndex;
+ }
+ } else {
+ // Original non-animated behavior
+ const spread = SPREADS[spreadIndex];
+ container.innerHTML = '';
+ setupSpreadLayout(container, spread, doc, false);
+ if (onComplete) onComplete();
+ }
+}
+
// Scale spread container to fit viewport
export function scaleSpreadToFit(container, doc, bottomPadding = 0) {
+ // Find the book container if in flip mode
+ const bookContainer = container.querySelector('.zine-book') || container;
+
const vw = doc.documentElement.clientWidth;
const vh = doc.documentElement.clientHeight - bottomPadding;
- const spreadWidthPx = container.offsetWidth;
- const spreadHeightPx = container.offsetHeight;
+ const spreadWidthPx = bookContainer.offsetWidth;
+ const spreadHeightPx = bookContainer.offsetHeight;
if (spreadWidthPx === 0 || spreadHeightPx === 0) return;
const scaleX = (vw - 40) / spreadWidthPx;
const scaleY = (vh - 40) / spreadHeightPx;
const scale = Math.min(scaleX, scaleY);
- container.style.transform = `scale(${scale})`;
+ bookContainer.style.transform = `scale(${scale})`;
}
diff --git a/resources/spread-navigation.js b/resources/spread-navigation.js
@@ -41,14 +41,36 @@ export function navigateSpread(delta, updatePreviewCallback) {
if (newSpread >= 0 && newSpread < SPREADS.length) {
currentSpread = newSpread;
updateSpreadIndicator();
- updatePreviewCallback();
- // Refocus preview so keyboard navigation continues to work
- document.getElementById('preview').focus();
+
+ // Try to animate if flip mode is enabled
+ const preview = document.getElementById('preview');
+ try {
+ const doc = preview.contentDocument;
+ const container = doc?._zineContainer;
+ const flipModeEnabled = doc?._flipModeEnabled;
+
+ if (container && flipModeEnabled) {
+ // Dynamic import to avoid circular dependency
+ import('./spread-layout.js').then(({ navigateToSpread }) => {
+ navigateToSpread(container, newSpread, doc, true, () => {
+ // Refocus preview so keyboard navigation continues to work
+ preview.focus();
+ });
+ });
+ } else {
+ updatePreviewCallback();
+ preview.focus();
+ }
+ } catch (e) {
+ // Fallback to regular update
+ updatePreviewCallback();
+ preview.focus();
+ }
}
}
// Fullscreen toggle
-export function toggleFullscreen(updatePreviewCallback) {
+export function toggleFullscreen() {
isFullscreen = !isFullscreen;
const editorPane = document.querySelector('.editor-pane');
const previewPane = document.querySelector('.preview-pane');
diff --git a/resources/viewer-generator.js b/resources/viewer-generator.js
@@ -1,5 +1,5 @@
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';
+import { ZINE_PAGE_CSS, ZINE_PRINT_CSS, ZINE_FLIP_CSS } from './zine-styles.js';
// Generate standalone viewer CSS and JS (matches preview pane rendering)
export function generateViewerCode() {
@@ -22,6 +22,19 @@ export function generateViewerCode() {
display: none !important;
}
${ZINE_PAGE_CSS}
+ ${ZINE_FLIP_CSS}
+
+ /* Dynamic z-index based on leaf state */
+ .zine-leaf[data-state="closed"][data-leaf-index="0"] { z-index: 14 !important; }
+ .zine-leaf[data-state="closed"][data-leaf-index="1"] { z-index: 13 !important; }
+ .zine-leaf[data-state="closed"][data-leaf-index="2"] { z-index: 12 !important; }
+ .zine-leaf[data-state="closed"][data-leaf-index="3"] { z-index: 11 !important; }
+ .zine-leaf[data-state="open"][data-leaf-index="0"] { z-index: 4 !important; }
+ .zine-leaf[data-state="open"][data-leaf-index="1"] { z-index: 3 !important; }
+ .zine-leaf[data-state="open"][data-leaf-index="2"] { z-index: 2 !important; }
+ .zine-leaf[data-state="open"][data-leaf-index="3"] { z-index: 1 !important; }
+ .zine-leaf[data-state="flipping"] { z-index: 20 !important; }
+
.zine-spread-container {
display: flex !important;
position: relative;
@@ -65,25 +78,141 @@ export function generateViewerCode() {
const NAV_HEIGHT = ${NAV_HEIGHT};
let currentSpread = 0;
let container;
+ let isAnimating = false;
+ const USE_FLIP_ANIMATION = true;
+
+ // Page leaf structure (front and back of each physical page)
+ const PAGE_LEAVES = [
+ { front: 'front-cover', back: 'page1' },
+ { front: 'page2', back: 'page3' },
+ { front: 'page4', back: 'page5' },
+ { front: 'page6', back: 'back-cover' }
+ ];
+
+ function easeInOutCubic(t) {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+ }
function scaleToFit() {
if (!container) return;
+ const bookContainer = container.querySelector('.zine-book') || container;
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight - NAV_HEIGHT;
- const spreadWidthPx = container.offsetWidth;
- const spreadHeightPx = container.offsetHeight;
+ const spreadWidthPx = bookContainer.offsetWidth;
+ const spreadHeightPx = bookContainer.offsetHeight;
if (spreadWidthPx === 0 || spreadHeightPx === 0) return;
const scaleX = (vw - 40) / spreadWidthPx;
const scaleY = (vh - 40) / spreadHeightPx;
const scale = Math.min(scaleX, scaleY);
- container.style.transform = 'scale(' + scale + ')';
+ bookContainer.style.transform = 'scale(' + scale + ')';
+ }
+
+ function initFlipMode() {
+ const book = document.createElement('div');
+ book.className = 'zine-book';
+
+ // Create all leaves
+ PAGE_LEAVES.forEach((leaf, index) => {
+ const leafEl = document.createElement('div');
+ leafEl.className = 'zine-leaf';
+ leafEl.dataset.leafIndex = index;
+ leafEl.dataset.state = 'closed';
+ leafEl.style.zIndex = String(20 - index); // Initial z-index for closed state
+
+ const frontSide = document.createElement('div');
+ frontSide.className = 'zine-leaf-front';
+ const frontPage = document.getElementById(leaf.front);
+ if (frontPage) frontSide.appendChild(frontPage.cloneNode(true));
+
+ const backSide = document.createElement('div');
+ backSide.className = 'zine-leaf-back';
+ const backPage = document.getElementById(leaf.back);
+ if (backPage) backSide.appendChild(backPage.cloneNode(true));
+
+ leafEl.appendChild(frontSide);
+ leafEl.appendChild(backSide);
+ book.appendChild(leafEl);
+ });
+
+ container.appendChild(book);
}
- function showSpread(index) {
+ function animateFlip(fromSpread, toSpread, onComplete) {
+ if (isAnimating) return;
+ isAnimating = true;
+
+ const direction = toSpread > fromSpread ? 'forward' : 'backward';
+ const leaves = document.querySelectorAll('.zine-leaf');
+
+ if (direction === 'forward') {
+ const leafToFlip = leaves[fromSpread];
+ if (leafToFlip) {
+ leafToFlip.dataset.state = 'flipping';
+ leafToFlip.style.zIndex = '100';
+ const startTime = performance.now();
+ const duration = 600;
+
+ function animate(currentTime) {
+ const elapsed = currentTime - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+ const eased = easeInOutCubic(progress);
+ const rotation = eased * -180;
+ leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)';
+
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ } else {
+ leafToFlip.dataset.state = 'open';
+ leafToFlip.style.zIndex = String(fromSpread + 1);
+ isAnimating = false;
+ if (onComplete) onComplete();
+ }
+ }
+ requestAnimationFrame(animate);
+ }
+ } else {
+ const leafToFlip = leaves[toSpread];
+ if (leafToFlip) {
+ leafToFlip.dataset.state = 'flipping';
+ leafToFlip.style.zIndex = '100';
+ const startTime = performance.now();
+ const duration = 600;
+
+ function animate(currentTime) {
+ const elapsed = currentTime - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+ const eased = easeInOutCubic(progress);
+ const rotation = -180 + (eased * 180);
+ leafToFlip.style.transform = 'rotateY(' + rotation + 'deg)';
+
+ if (progress < 1) {
+ requestAnimationFrame(animate);
+ } else {
+ leafToFlip.dataset.state = 'closed';
+ leafToFlip.style.zIndex = String(20 - toSpread);
+ isAnimating = false;
+ if (onComplete) onComplete();
+ }
+ }
+ requestAnimationFrame(animate);
+ }
+ }
+ }
+
+ function showSpread(index, animated) {
const spread = SPREADS[index];
- const visible = [spread.left, spread.right].filter(Boolean);
- // Move existing pages back to body before clearing container
+ if (USE_FLIP_ANIMATION && animated && container.querySelector('.zine-book')) {
+ animateFlip(currentSpread, index, () => {
+ document.getElementById('zine-indicator').textContent = spread.label;
+ document.getElementById('zine-prev').disabled = index === 0;
+ document.getElementById('zine-next').disabled = index === SPREADS.length - 1;
+ });
+ return;
+ }
+
+ // Non-animated or initial setup
+ const visible = [spread.left, spread.right].filter(Boolean);
PAGE_IDS.forEach(id => {
const page = document.getElementById(id);
if (page) {
@@ -123,8 +252,9 @@ export function generateViewerCode() {
function navigate(delta) {
const newIndex = currentSpread + delta;
if (newIndex >= 0 && newIndex < SPREADS.length) {
+ const oldSpread = currentSpread;
currentSpread = newIndex;
- showSpread(currentSpread);
+ showSpread(currentSpread, true);
}
}
@@ -179,7 +309,20 @@ export function generateViewerCode() {
document.getElementById('zine-prev').addEventListener('click', () => navigate(-1));
document.getElementById('zine-next').addEventListener('click', () => navigate(1));
- showSpread(currentSpread);
+ if (USE_FLIP_ANIMATION) {
+ initFlipMode();
+ const leaves = document.querySelectorAll('.zine-leaf');
+ leaves.forEach((leaf, index) => {
+ if (index < currentSpread) {
+ leaf.dataset.state = 'open';
+ leaf.style.transform = 'rotateY(-180deg)';
+ leaf.style.zIndex = String(index + 1);
+ }
+ });
+ } else {
+ showSpread(currentSpread, false);
+ }
+ scaleToFit();
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
@@ -193,7 +336,16 @@ export function generateViewerCode() {
window.addEventListener('resize', scaleToFit);
- showSpread(0);
+ // Initialize flip mode
+ if (USE_FLIP_ANIMATION) {
+ initFlipMode();
+ document.getElementById('zine-indicator').textContent = SPREADS[0].label;
+ document.getElementById('zine-prev').disabled = true;
+ document.getElementById('zine-next').disabled = false;
+ scaleToFit();
+ } else {
+ showSpread(0, false);
+ }
});
`;
diff --git a/resources/zine-styles.js b/resources/zine-styles.js
@@ -41,6 +41,95 @@ export const ZINE_PRINT_CSS = `
a { color: black; }
`;
+// CSS for 3D page flip animation
+export const ZINE_FLIP_CSS = `
+ .zine-book {
+ position: relative;
+ width: ${PAGE_WIDTH_IN * 2}in;
+ height: ${PAGE_HEIGHT_IN}in;
+ perspective: 2000px;
+ transform-style: preserve-3d;
+ transform-origin: center center;
+ }
+
+ .zine-leaf {
+ position: absolute;
+ width: ${PAGE_WIDTH_IN}in;
+ height: ${PAGE_HEIGHT_IN}in;
+ left: ${PAGE_WIDTH_IN}in;
+ top: 0;
+ transform-origin: left center;
+ transform-style: preserve-3d;
+ transition: none;
+ }
+
+ /* When flipping, use high z-index so it's on top during animation */
+ .zine-leaf[data-state="flipping"] {
+ z-index: 20 !important;
+ transition: none;
+ }
+
+ .zine-leaf-front,
+ .zine-leaf-back {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ backface-visibility: hidden;
+ -webkit-backface-visibility: hidden;
+ }
+
+ .zine-leaf-front {
+ transform: rotateY(0deg);
+ z-index: 2;
+ }
+
+ .zine-leaf-back {
+ transform: rotateY(180deg);
+ z-index: 1;
+ }
+
+ .zine-leaf-front .page,
+ .zine-leaf-back .page {
+ width: 100%;
+ height: 100%;
+ display: block !important;
+ }
+
+ /* Base pages - shown beneath the flipping leaves */
+ .zine-base-pages {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 0;
+ }
+
+ .zine-base-page {
+ position: absolute;
+ width: ${PAGE_WIDTH_IN}in;
+ height: ${PAGE_HEIGHT_IN}in;
+ display: none !important;
+ z-index: 0;
+ }
+
+ /* Position base pages */
+ .zine-base-page[data-page-id="front-cover"],
+ .zine-base-page[data-page-id="page2"],
+ .zine-base-page[data-page-id="page4"],
+ .zine-base-page[data-page-id="page6"] {
+ right: 0;
+ }
+
+ .zine-base-page[data-page-id="page1"],
+ .zine-base-page[data-page-id="page3"],
+ .zine-base-page[data-page-id="page5"],
+ .zine-base-page[data-page-id="back-cover"] {
+ left: 0;
+ }
+`;
+
// Shared CSS for zine pages (used by both editor preview and exported viewer)
export const ZINE_PAGE_CSS = `
${ALL_PAGES_SELECTOR} {