commit 37eb49448871d2fcacbf623e5cfa2103180f83bf
parent 51d72f63ad63a2f68653693f571c9a16cd16b00b
Author: Hunter
Date:   Mon,  2 Feb 2026 17:24:23 -0500

refactor; break up editor.js

Diffstat:
Aresources/codemirror-config.js | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/constants.js | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/editor.js | 1043++++---------------------------------------------------------------------------
Aresources/file-operations.js | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/html-utils.js | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/mobile-keyboard.js | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/preview-manager.js | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/spread-layout.js | 35+++++++++++++++++++++++++++++++++++
Aresources/spread-navigation.js | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/storage.js | 38++++++++++++++++++++++++++++++++++++++
Aresources/viewer-generator.js | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/zine-styles.js | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1119 insertions(+), 1001 deletions(-)

diff --git a/resources/codemirror-config.js b/resources/codemirror-config.js @@ -0,0 +1,177 @@ +import { ZINE_BOILERPLATE, getBoilerplateCursorPos } from './constants.js'; +import { loadContent, loadEditorSettings, saveEditorSetting } from './storage.js'; + +let lineNumbersCompartment; +let lineWrappingCompartment; +let showLineNumbers = false; +let enableLineWrapping = false; + +export function toggleLineNumbers(editorView) { + const {lineNumbers} = window.CodeMirror; + showLineNumbers = !showLineNumbers; + saveEditorSetting('zine-editor-line-numbers', showLineNumbers); + editorView.dispatch({ + effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : []) + }); +} + +function createLineWrappingExtension() { + const {EditorView, Decoration} = window.CodeMirror; + + return [ + EditorView.lineWrapping, + EditorView.decorations.of((view) => { + const decorations = []; + for (let {from, to} of view.visibleRanges) { + for (let pos = from; pos <= to;) { + const line = view.state.doc.lineAt(pos); + const lineText = line.text; + let indentChars = 0; + for (let i = 0; i < lineText.length; i++) { + if (lineText[i] === '\t') { + indentChars += 2; + } else if (lineText[i] === ' ') { + indentChars += 1; + } else { + break; + } + } + if (indentChars > 0) { + const indentDecoration = Decoration.line({ + attributes: { + style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);` + } + }); + decorations.push(indentDecoration.range(line.from)); + } + pos = line.to + 1; + } + } + return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none; + }), + ]; +} + +export function toggleLineWrapping(editorView) { + enableLineWrapping = !enableLineWrapping; + saveEditorSetting('zine-editor-line-wrapping', enableLineWrapping); + const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; + editorView.dispatch({ + effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) + }); +} + +// Initialize CodeMirror +export async function initializeCodeMirror(saveToStorageCallback, updatePreviewCallback) { + if (!window.CodeMirror) { + setTimeout(() => initializeCodeMirror(saveToStorageCallback, updatePreviewCallback), 100); + return; + } + + const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, history, closeBrackets, search, searchKeymap, lineNumbers} = window.CodeMirror; + + const customPhrases = EditorState.phrases.of({ + "Find": "Find..." + }); + + const { content: savedContent, isBoilerplate } = await loadContent(); + const settings = loadEditorSettings(); + showLineNumbers = settings.showLineNumbers; + enableLineWrapping = settings.enableLineWrapping; + + lineNumbersCompartment = new Compartment(); + lineWrappingCompartment = new Compartment(); + + const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; + + const stateConfig = { + doc: savedContent, + extensions: [ + customPhrases, + history(), + search(), + closeBrackets(), + keymap.of([ + {key: "Mod-z", run: undo}, + {key: "Mod-y", run: redo}, + {key: "Mod-Shift-z", run: redo}, + {key: "Mod-o", run: () => { window.loadFile(); return true; }}, + {key: "Mod-s", run: () => { window.saveFile(); return true; }}, + {key: "Mod-e", run: () => { window.saveFileWithViewer(); return true; }}, + {key: "F1", run: (view) => { toggleLineNumbers(view); return true; }}, + {key: "F2", run: (view) => { toggleLineWrapping(view); return true; }}, + indentWithTab, + ...searchKeymap.filter(binding => binding.key !== "Mod-f"), + ...defaultKeymap + ]), + html(), + EditorView.inputHandler.of((view, from, to, text) => { + if (text !== '>') return false; + const before = view.state.doc.sliceString(Math.max(0, from - 20), from); + if (before.endsWith('<!')) { + const startPos = from - 2; + view.dispatch({ + changes: { from: startPos, to: from, insert: ZINE_BOILERPLATE }, + selection: { anchor: getBoilerplateCursorPos(startPos) } + }); + return true; + } + const match = before.match(/<(style|script)(\s[^>]*)?$/i); + if (!match) return false; + const tagName = match[1].toLowerCase(); + const closingTag = `</${tagName}>`; + view.dispatch({ + changes: { from, to, insert: '>' + closingTag }, + selection: { anchor: from + 1 } + }); + return true; + }), + githubDark, + indentUnit.of("\t"), + placeholder("Type \"<!>\" to insert zine outline..."), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + const content = update.state.doc.toString(); + clearTimeout(window.updateTimer); + window.updateTimer = setTimeout(() => updatePreviewCallback(content), 600); + saveToStorageCallback(content); + } + }), + EditorView.contentAttributes.of({ + 'autocomplete': 'off', + 'autocorrect': 'off', + 'autocapitalize': 'off', + 'spellcheck': 'false' + }), + lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), + lineWrappingCompartment.of(initialLineWrappingExtension) + ] + }; + + const editorView = new EditorView({ + state: EditorState.create(stateConfig), + parent: document.getElementById('editor') + }); + + if (isBoilerplate) { + editorView.dispatch({ + selection: { anchor: getBoilerplateCursorPos() } + }); + } + + // Disable browser autocomplete on search panel inputs + const editorElement = document.getElementById('editor'); + const searchInputObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + const searchInputs = node.querySelectorAll?.('.cm-search input[name="search"], .cm-search input[name="replace"]'); + searchInputs?.forEach(input => input.setAttribute('autocomplete', 'off')); + } + } + } + }); + searchInputObserver.observe(editorElement, { childList: true, subtree: true }); + + return editorView; +} diff --git a/resources/constants.js b/resources/constants.js @@ -0,0 +1,90 @@ +// Zine page dimensions in inches +export const PAGE_WIDTH_IN = 2.75; +export const PAGE_HEIGHT_IN = 4.25; +export const PAGE_PADDING_IN = 0.2; + +// All page IDs for selectors +export const PAGE_IDS = ['front-cover', 'page1', 'page2', 'page3', 'page4', 'page5', 'page6', 'back-cover']; +export const ALL_PAGES_SELECTOR = PAGE_IDS.map(id => `#${id}`).join(', '); +export const RIGHT_PAGES_SELECTOR = '#front-cover, #page2, #page4, #page6'; +export const LEFT_PAGES_SELECTOR = '#back-cover, #page1, #page3, #page5'; + +// Spread definitions +export const SPREADS = [ + { left: null, right: 'front-cover', label: 'Front Cover' }, + { left: 'page1', right: 'page2', label: 'Pages 1-2' }, + { left: 'page3', right: 'page4', label: 'Pages 3-4' }, + { left: 'page5', right: 'page6', label: 'Pages 5-6' }, + { left: 'back-cover', right: null, label: 'Back Cover' } +]; + +// Height of nav bar for layout calculations +export const NAV_HEIGHT = 60; + +// Shared navigation button CSS (used by both editor spread-nav and exported viewer zine-nav) +export const NAV_BUTTON_CSS = ` + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 12px 20px; +`; + +export const NAV_BUTTON_STYLES = ` + background: rgba(255,255,255,0.15); + color: white; + border: none; + border-radius: 50%; + width: 36px; + height: 36px; + cursor: pointer; + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; +`; + +// Zine HTML boilerplate template +export const ZINE_BOILERPLATE = `<!DOCTYPE html> +<html> + <head> + <style> + + </style> + </head> + <body> + <div class="page" id="front-cover"> + + </div> + <div class="page" id="page1"> + + </div> + <div class="page" id="page2"> + + </div> + <div class="page" id="page3"> + + </div> + <div class="page" id="page4"> + + </div> + <div class="page" id="page5"> + + </div> + <div class="page" id="page6"> + + </div> + <div class="page" id="back-cover"> + + </div> + </body> +</html> +`; + +// Get cursor position for boilerplate (inside front-cover div) +export function getBoilerplateCursorPos(startPos = 0) { + return startPos + ZINE_BOILERPLATE.indexOf('<div class="page" id="front-cover">') + 39; +} + +// Storage key +export const STORAGE_KEY = 'zine-editor-content'; diff --git a/resources/editor.js b/resources/editor.js @@ -1,345 +1,31 @@ -let updateTimer; +import { saveToStorage } from './storage.js'; +import { saveFile, saveFileWithViewer, loadFile } from './file-operations.js'; +import { updatePreview, setupPreviewPane } from './preview-manager.js'; +import { navigateSpread, toggleFullscreen, initializeSpreadNav, getIsFullscreen } from './spread-navigation.js'; +import { initializeCodeMirror } from './codemirror-config.js'; +import { isMobileDevice, exitMobileKeyboardMode, initializeMobileKeyboard } from './mobile-keyboard.js'; + +// Global state let editorView; -const preview = document.getElementById('preview'); -const editorPane = document.querySelector('.editor-pane'); -const previewPane = document.querySelector('.preview-pane'); -const storageKey = 'zine-editor-content'; - -// Insert content right after <head> tag in HTML string -function insertAfterHead(html, content) { - const headMatch = html.match(/<head[^>]*>/i); - if (headMatch) { - const insertPos = html.indexOf(headMatch[0]) + headMatch[0].length; - return html.slice(0, insertPos) + content + html.slice(insertPos); - } - return html; -} - -// Insert content right before </head> tag in HTML string -function insertBeforeHeadClose(html, content) { - const headEndMatch = html.match(/<\/head>/i); - if (headEndMatch) { - const insertPos = html.indexOf(headEndMatch[0]); - return html.slice(0, insertPos) + content + html.slice(insertPos); - } - return html; -} - -// Extract title and favicon from user's HTML -function extractTitleAndFavicon(htmlCode) { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlCode, 'text/html'); - const titleElement = doc.querySelector('title'); - const title = titleElement ? titleElement.textContent.trim() : null; - const faviconSelectors = [ - 'link[rel="icon"]', - 'link[rel="shortcut icon"]', - 'link[rel="apple-touch-icon"]' - ]; - let favicon = null; - for (const selector of faviconSelectors) { - const faviconElement = doc.querySelector(selector); - if (faviconElement && faviconElement.getAttribute('href')) { - favicon = faviconElement.getAttribute('href'); - break; - } - } - return { title, favicon }; -} - -function updateMainPageTitleAndFavicon(title, favicon) { - if (title) { - document.title = title; - } else { - document.title = 'zine.html'; - } - let faviconLink = document.querySelector('link[rel="icon"]'); - if (!faviconLink) { - faviconLink = document.createElement('link'); - faviconLink.rel = 'icon'; - document.head.appendChild(faviconLink); - } - if (favicon) { - faviconLink.href = favicon; - } else { - faviconLink.href = 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖨️</text></svg>'; - } -} - -// Zine HTML boilerplate template -const ZINE_BOILERPLATE = `<!DOCTYPE html> -<html> -\t<head> -\t\t<style> -\t\t\t -\t\t</style> -\t</head> -\t<body> -\t\t<div class="page" id="front-cover"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page1"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page2"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page3"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page4"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page5"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="page6"> -\t\t\t -\t\t</div> -\t\t<div class="page" id="back-cover"> -\t\t\t -\t\t</div> -\t</body> -</html> -`; - -// Get cursor position for boilerplate (inside front-cover div) -function getBoilerplateCursorPos(startPos = 0) { - return startPos + ZINE_BOILERPLATE.indexOf('<div class="page" id="front-cover">') + 39; -} - -// Zine page dimensions in inches -const PAGE_WIDTH_IN = 2.75; -const PAGE_HEIGHT_IN = 4.25; -const PAGE_PADDING_IN = 0.2; - -// All page IDs for selectors -const PAGE_IDS = ['front-cover', 'page1', 'page2', 'page3', 'page4', 'page5', 'page6', 'back-cover']; -const ALL_PAGES_SELECTOR = PAGE_IDS.map(id => `#${id}`).join(', '); -const RIGHT_PAGES_SELECTOR = '#front-cover, #page2, #page4, #page6'; -const LEFT_PAGES_SELECTOR = '#back-cover, #page1, #page3, #page5'; - -// Create an empty placeholder div for single-page spreads -function createEmptyPlaceholder(doc) { - const empty = doc.createElement('div'); - empty.className = 'zine-empty'; - return empty; -} - -// Spread definitions -const SPREADS = [ - { left: null, right: 'front-cover', label: 'Front Cover' }, - { left: 'page1', right: 'page2', label: 'Pages 1-2' }, - { left: 'page3', right: 'page4', label: 'Pages 3-4' }, - { left: 'page5', right: 'page6', label: 'Pages 5-6' }, - { left: 'back-cover', right: null, label: 'Back Cover' } -]; - -let currentSpread = 0; -let isFullscreen = false; -let showLineNumbers = false; -let enableLineWrapping = false; -let lineNumbersCompartment; -let lineWrappingCompartment; let isEditorFocused = false; -let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; - -// Shared print CSS for zine layout (used by both editor preview and exported viewer) -const ZINE_PRINT_CSS = ` - @page { size: 8.5in 11in portrait; margin: 0; } - html, body { width: 8.5in; height: 11in; } - body { - display: grid !important; - grid-template-columns: repeat(4, ${PAGE_WIDTH_IN}in); - grid-template-rows: repeat(2, ${PAGE_HEIGHT_IN}in); - transform: rotate(90deg); - transform-origin: center center; - position: absolute; - top: calc(50% - ${PAGE_HEIGHT_IN}in); - left: calc(50% - 5.5in); - width: 11in; - height: 8.5in; - } - ${ALL_PAGES_SELECTOR} { - display: block !important; - width: ${PAGE_WIDTH_IN}in !important; - height: ${PAGE_HEIGHT_IN}in !important; - padding: ${PAGE_PADDING_IN}in; - background: white; - overflow: hidden; - overflow-wrap: break-word; - aspect-ratio: auto !important; - box-shadow: none !important; - transform: none; - } - #front-cover { grid-row: 2; grid-column: 2; } - #page1 { grid-row: 2; grid-column: 3; } - #page2 { grid-row: 2; grid-column: 4; } - #page3 { grid-row: 1; grid-column: 4; transform: rotate(180deg); } - #page4 { grid-row: 1; grid-column: 3; transform: rotate(180deg); } - #page5 { grid-row: 1; grid-column: 2; transform: rotate(180deg); } - #page6 { grid-row: 1; grid-column: 1; transform: rotate(180deg); } - #back-cover { grid-row: 2; grid-column: 1; } - .zine-nav, .zine-empty, .spread-nav, .iframe-fullscreen-toggle, .zine-spread-container { display: contents !important; } - .zine-spread-container { transform: none !important; } - a { color: black; } -`; - -// Shared CSS for zine pages (used by both editor preview and exported viewer) -const ZINE_PAGE_CSS = ` - ${ALL_PAGES_SELECTOR} { - display: none; - width: ${PAGE_WIDTH_IN}in; - height: ${PAGE_HEIGHT_IN}in; - flex-shrink: 0; - padding: ${PAGE_PADDING_IN}in; - overflow: hidden; - overflow-wrap: break-word; - } - .page { - background: white; - } - .zine-spread-container { - display: flex; - transform-origin: center center; - } - ${RIGHT_PAGES_SELECTOR} { - box-shadow: inset 4px 0 1.3px -3px rgba(0, 0, 0, 0.09), inset 8px 0 6px -6px rgba(0, 0, 0, 0.15); - } - ${LEFT_PAGES_SELECTOR} { - box-shadow: inset -4px 0 1.5px -3px rgba(0, 0, 0, 0.09), inset -8px 0 6px -6px rgba(0, 0, 0, 0.15); - } - .zine-empty { - width: ${PAGE_WIDTH_IN}in; - height: ${PAGE_HEIGHT_IN}in; - flex-shrink: 0; - border: 1px dashed #666; - } - .zine-empty:first-child { - border-right: none; - } - .zine-empty:last-child { - border-left: none; - } -`; - -// Generate CSS to show only the current spread -function generateSpreadCSS(spreadIndex) { - const spread = SPREADS[spreadIndex]; - const visiblePages = [spread.left, spread.right].filter(Boolean); - - return ` - /* Base styles for all views */ - * { margin: 0; padding: 0; box-sizing: border-box; } - - /* Screen view: show spread at actual size, then scale to fit */ - @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-spread-container { - display: flex !important; - position: relative; - } - - ${ALL_PAGES_SELECTOR} { - display: none !important; - width: ${PAGE_WIDTH_IN}in !important; - height: ${PAGE_HEIGHT_IN}in !important; - } - - /* Show visible pages */ - ${visiblePages.map(id => `#${id}`).join(', ')} { - display: block !important; - } - .zine-empty { - display: block !important; - width: ${PAGE_WIDTH_IN}in !important; - height: ${PAGE_HEIGHT_IN}in !important; - } - } - - /* Print view: 8-page grid for mini-zine folding */ - @media print { - ${ZINE_PRINT_CSS} - } - `; -} - -// Update spread indicator and button states -function updateSpreadIndicator() { - const indicator = document.getElementById('spreadIndicator'); - const prevBtn = document.getElementById('prevSpread'); - const nextBtn = document.getElementById('nextSpread'); - - indicator.textContent = SPREADS[currentSpread].label; - prevBtn.disabled = currentSpread === 0; - nextBtn.disabled = currentSpread === SPREADS.length - 1; +// Wrapper functions for updatePreview +function updatePreviewWrapper() { + updatePreview(editorView, isEditorFocused); } -// Navigate between spreads -function navigateSpread(delta) { - const newSpread = currentSpread + delta; - if (newSpread >= 0 && newSpread < SPREADS.length) { - currentSpread = newSpread; - updateSpreadIndicator(); - updatePreview(); - // Refocus preview so keyboard navigation continues to work - preview.focus(); - } -} - -// Fullscreen toggle -function toggleFullscreen() { - isFullscreen = !isFullscreen; - - if (isFullscreen) { - previewPane.classList.add('fullscreen'); - editorPane.classList.add('hidden'); - preview.style.height = window.innerHeight + 'px'; - } else { - previewPane.classList.remove('fullscreen'); - editorPane.classList.remove('hidden'); - preview.style.height = ''; - } - - try { - const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); - if (toggleButton) { - toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; - } - } catch (e) {} - - // Trigger resize after layout updates so scaleToFit() recalculates - requestAnimationFrame(() => { - try { - preview.contentWindow.dispatchEvent(new Event('resize')); - } catch (e) {} - }); +function updatePreviewFromContent() { + updatePreviewWrapper(); } +// Message handler for iframe communication window.addEventListener('message', function(event) { if (event.data === 'toggleFullscreen') { - toggleFullscreen(); + toggleFullscreen(updatePreviewWrapper); } else if (event.data === 'prevSpread') { - navigateSpread(-1); + navigateSpread(-1, updatePreviewWrapper); } else if (event.data === 'nextSpread') { - navigateSpread(1); + navigateSpread(1, updatePreviewWrapper); } else if (event.data === 'saveFile') { window.saveFile(); } else if (event.data === 'saveFileWithViewer') { @@ -347,621 +33,38 @@ window.addEventListener('message', function(event) { } }); -function updatePreview() { - if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) { - return; - } - - const code = editorView.state.doc.toString(); - - // Extract and update title and favicon from user's HTML - const { title, favicon } = extractTitleAndFavicon(code); - updateMainPageTitleAndFavicon(title, favicon); - - let scrollX = 0, scrollY = 0; - try { - if (preview.contentWindow?.scrollX !== undefined) { - scrollX = preview.contentWindow.scrollX; - scrollY = preview.contentWindow.scrollY; - } - } catch (e) {} - - // Inject spread CSS right after <head> so user styles come last and take precedence - const spreadCSS = 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>'; - - const onLoad = () => { - try { - const doc = preview.contentDocument; - if (!doc) return; - - // Add CSS for overscroll and fullscreen button - const style = doc.createElement('style'); - style.textContent = '* { overscroll-behavior: none !important; } .iframe-fullscreen-toggle { position: fixed; top: 5px; right: 5px; z-index: 10000; background: rgba(0, 0, 0, 0.2); color: white; border: none; border-radius: 4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -webkit-tap-highlight-color: transparent; outline: none; user-select: none; } .iframe-fullscreen-toggle svg { filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); opacity: 0.8; transition: opacity 0.2s; } @media (hover: hover) and (pointer: fine) { .iframe-fullscreen-toggle:hover { background: rgba(0, 0, 0, 0.35); } .iframe-fullscreen-toggle:hover svg { opacity: 1; } }'; - doc.head.appendChild(style); - - // Create fullscreen button - const existingButton = doc.getElementById('fullscreenToggle'); - if (existingButton) existingButton.remove(); - - const button = doc.createElement('button'); - button.id = 'fullscreenToggle'; - button.className = 'iframe-fullscreen-toggle'; - button.title = 'Toggle fullscreen'; - button.innerHTML = '<svg width="20" height="20" viewBox="0 0 14 14" fill="white"><path d="M 7,14 H 5 v 5 h 5 V 17 H 7 Z M 5,10 H 7 V 7 h 3 V 5 H 5 Z m 12,7 h -3 v 2 h 5 V 14 H 17 Z M 14,5 v 2 h 3 v 3 h 2 V 5 Z" transform="translate(-5,-5)"/></svg>'; - button.addEventListener('click', function() { - parent.postMessage('toggleFullscreen', '*'); - }); - - if (doc.body) { - doc.body.appendChild(button); - - // Create container for the spread and wrap visible pages - const spread = SPREADS[currentSpread]; - const container = doc.createElement('div'); - container.className = 'zine-spread-container'; - - // Add empty placeholder before right page if no left page - if (!spread.left && spread.right) { - container.appendChild(createEmptyPlaceholder(doc)); - } - - // Move visible pages into container - const visiblePages = [spread.left, spread.right].filter(Boolean); - visiblePages.forEach(id => { - const page = doc.getElementById(id); - if (page) container.appendChild(page); - }); - - // Add empty placeholder after left page if no right page - if (spread.left && !spread.right) { - container.appendChild(createEmptyPlaceholder(doc)); - } - - doc.body.appendChild(container); - - // Scale container to fit viewport - const scaleToFit = () => { - const vw = doc.documentElement.clientWidth; - const vh = doc.documentElement.clientHeight; - const spreadWidthPx = container.offsetWidth; - const spreadHeightPx = container.offsetHeight; - const scaleX = (vw - 40) / spreadWidthPx; - const scaleY = (vh - 40) / spreadHeightPx; - const scale = Math.min(scaleX, scaleY); - container.style.transform = `scale(${scale})`; - }; - scaleToFit(); - doc.defaultView.addEventListener('resize', scaleToFit); - } - - // Add keyboard listener for navigation and shortcuts - doc.addEventListener('keydown', function(e) { - if (e.key === 'ArrowLeft') { - e.preventDefault(); - parent.postMessage('prevSpread', '*'); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - parent.postMessage('nextSpread', '*'); - } else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { - e.preventDefault(); - window.print(); - } else if ((e.metaKey || e.ctrlKey) && e.key === 's') { - e.preventDefault(); - parent.postMessage('saveFile', '*'); - } else if ((e.metaKey || e.ctrlKey) && e.key === 'e') { - e.preventDefault(); - parent.postMessage('saveFileWithViewer', '*'); - } - }); - - setTimeout(() => { - try { - preview.contentWindow?.scrollTo(scrollX, scrollY); - } catch (e) {} - }, 10); - } catch (e) {} - - preview.removeEventListener('load', onLoad); - }; - preview.addEventListener('load', onLoad); -} - -function saveToStorage() { - try { - localStorage.setItem(storageKey, editorView.state.doc.toString()); - } catch (e) { - console.warn('Could not save to localStorage:', e); - } -} - -async function loadContent() { - let content = ZINE_BOILERPLATE; - try { - const saved = localStorage.getItem(storageKey); - if (saved && saved.trim()) { - content = saved; - } - } catch (e) { - console.warn('Could not load from localStorage:', e); - } - return { content, isBoilerplate: content === ZINE_BOILERPLATE }; -} - -function loadEditorSettings() { - try { - showLineNumbers = localStorage.getItem('zine-editor-line-numbers') === 'true'; - enableLineWrapping = localStorage.getItem('zine-editor-line-wrapping') !== 'false'; - } catch (e) { - showLineNumbers = false; - enableLineWrapping = true; - } -} - -function saveEditorSetting(key, value) { - try { - localStorage.setItem(key, value.toString()); - } catch (e) {} -} - -function toggleLineNumbers() { - const {lineNumbers} = window.CodeMirror; - showLineNumbers = !showLineNumbers; - saveEditorSetting('zine-editor-line-numbers', showLineNumbers); - editorView.dispatch({ - effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : []) - }); -} - -function createLineWrappingExtension() { - const {EditorView, Decoration} = window.CodeMirror; - - return [ - EditorView.lineWrapping, - EditorView.decorations.of((view) => { - const decorations = []; - for (let {from, to} of view.visibleRanges) { - for (let pos = from; pos <= to;) { - const line = view.state.doc.lineAt(pos); - const lineText = line.text; - let indentChars = 0; - for (let i = 0; i < lineText.length; i++) { - if (lineText[i] === '\t') { - indentChars += 2; - } else if (lineText[i] === ' ') { - indentChars += 1; - } else { - break; - } - } - if (indentChars > 0) { - const indentDecoration = Decoration.line({ - attributes: { - style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);` - } - }); - decorations.push(indentDecoration.range(line.from)); - } - pos = line.to + 1; - } - } - return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none; - }), - ]; -} - -function toggleLineWrapping() { - enableLineWrapping = !enableLineWrapping; - saveEditorSetting('zine-editor-line-wrapping', enableLineWrapping); - const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; - editorView.dispatch({ - effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) - }); -} - -// Shared navigation button CSS (used by both editor spread-nav and exported viewer zine-nav) -const NAV_BUTTON_CSS = ` - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - padding: 12px 20px; -`; - -const NAV_BUTTON_STYLES = ` - background: rgba(255,255,255,0.15); - color: white; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; - cursor: pointer; - font-size: 18px; - display: flex; - align-items: center; - justify-content: center; -`; - -// Height of nav bar for layout calculations -const NAV_HEIGHT = 60; - -// Generate standalone viewer CSS and JS (matches preview pane rendering) -function generateViewerCode() { - const css = ` - * { margin: 0; padding: 0; box-sizing: border-box; } - - @media screen { - html, body { - height: 100% !important; - overflow: hidden !important; - } - body { - background: linear-gradient(to top, #2a2a2a, #3a3a3a) !important; - display: flex !important; - justify-content: center !important; - align-items: center !important; - padding-bottom: ${NAV_HEIGHT}px !important; - } - body > *:not(.zine-spread-container):not(.zine-nav) { - display: none !important; - } - ${ZINE_PAGE_CSS} - .zine-spread-container { - display: flex !important; - position: relative; - } - .zine-nav { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: ${NAV_HEIGHT}px; - ${NAV_BUTTON_CSS} - background: transparent; - } - .zine-nav button { - ${NAV_BUTTON_STYLES} - } - .zine-nav button:hover:not(:disabled) { background: rgba(255,255,255,0.25); } - .zine-nav button:disabled { opacity: 0.3; cursor: default; } - .zine-nav span { color: white; font-size: 14px; min-width: 100px; text-align: center; font-family: monospace; } - } - - @media print { - ${ZINE_PRINT_CSS} - } - - /* Hide content when viewport is very short */ - @media screen and (max-height: 200px) { - .zine-spread-container, .zine-nav { - display: none !important; - } - } - `; - - // Serialize SPREADS array for the viewer - const spreadsJson = JSON.stringify(SPREADS); - const pageIdsJson = JSON.stringify(PAGE_IDS); - - const js = ` - const SPREADS = ${spreadsJson}; - const PAGE_IDS = ${pageIdsJson}; - const NAV_HEIGHT = ${NAV_HEIGHT}; - let currentSpread = 0; - let container; - - function scaleToFit() { - if (!container) return; - const vw = document.documentElement.clientWidth; - const vh = document.documentElement.clientHeight - NAV_HEIGHT; - const spreadWidthPx = container.offsetWidth; - const spreadHeightPx = container.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 + ')'; - } - - function showSpread(index) { - const spread = SPREADS[index]; - const visible = [spread.left, spread.right].filter(Boolean); - - // Move existing pages back to body before clearing container - PAGE_IDS.forEach(id => { - const page = document.getElementById(id); - if (page) { - page.style.display = 'none'; - document.body.appendChild(page); - } - }); - - container.innerHTML = ''; - - if (!spread.left && spread.right) { - const empty = document.createElement('div'); - empty.className = 'zine-empty'; - container.appendChild(empty); - } - - visible.forEach(id => { - const el = document.getElementById(id); - if (el) { - el.style.display = 'block'; - container.appendChild(el); - } - }); - - if (spread.left && !spread.right) { - const empty = document.createElement('div'); - empty.className = 'zine-empty'; - container.appendChild(empty); - } - - document.getElementById('zine-indicator').textContent = spread.label; - document.getElementById('zine-prev').disabled = index === 0; - document.getElementById('zine-next').disabled = index === SPREADS.length - 1; - scaleToFit(); - } - - function navigate(delta) { - const newIndex = currentSpread + delta; - if (newIndex >= 0 && newIndex < SPREADS.length) { - currentSpread = newIndex; - showSpread(currentSpread); - } - } - - document.addEventListener('DOMContentLoaded', () => { - container = document.createElement('div'); - container.className = 'zine-spread-container'; - document.body.appendChild(container); - - const nav = document.createElement('div'); - nav.className = 'zine-nav'; - nav.innerHTML = '<button id="zine-prev" title="Previous spread">\\u2190</button><span id="zine-indicator"></span><button id="zine-next" title="Next spread">\\u2192</button>'; - document.body.appendChild(nav); - - document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); - document.getElementById('zine-next').addEventListener('click', () => navigate(1)); - - document.addEventListener('keydown', e => { - if (e.key === 'ArrowLeft') navigate(-1); - else if (e.key === 'ArrowRight') navigate(1); - else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { - e.preventDefault(); - window.print(); - } - else if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'e')) { - e.preventDefault(); - // Re-download this viewer file (clean version without dynamic elements) - // Move pages back to body and remove dynamic elements temporarily - PAGE_IDS.forEach(id => { - const page = document.getElementById(id); - if (page) { - page.style.display = ''; - document.body.appendChild(page); - } - }); - const tempContainer = document.querySelector('.zine-spread-container'); - const tempNav = document.querySelector('.zine-nav'); - if (tempContainer) tempContainer.remove(); - if (tempNav) tempNav.remove(); - - const html = '<!DOCTYPE html>' + document.documentElement.outerHTML; - - // Restore dynamic elements - const newContainer = document.createElement('div'); - newContainer.className = 'zine-spread-container'; - document.body.appendChild(newContainer); - container = newContainer; - - const newNav = document.createElement('div'); - newNav.className = 'zine-nav'; - newNav.innerHTML = '<button id="zine-prev" title="Previous spread">\\u2190</button><span id="zine-indicator"></span><button id="zine-next" title="Next spread">\\u2192</button>'; - document.body.appendChild(newNav); - document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); - document.getElementById('zine-next').addEventListener('click', () => navigate(1)); - - showSpread(currentSpread); - - const blob = new Blob([html], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'my-zine.html'; - a.click(); - URL.revokeObjectURL(url); - } - }); - - window.addEventListener('resize', scaleToFit); - - showSpread(0); - }); - `; - - return { css, js }; -} - -// File operations -function downloadHtmlFile(content, filename = 'my-zine.html') { - const blob = new Blob([content], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - +// Expose file operations to window for keyboard shortcuts window.saveFile = function() { - downloadHtmlFile(editorView.state.doc.toString()); + saveFile(() => editorView.state.doc.toString()); }; window.saveFileWithViewer = function() { - const code = editorView.state.doc.toString(); - const { css, js } = generateViewerCode(); - - // Check if code has proper head tags - const hasHead = /<head[^>]*>/i.test(code) && /<\/head>/i.test(code); - - let processedCode; - if (hasHead) { - // Inject viewer CSS at START of head (so user styles take precedence for page content) - // Inject viewer JS at END of head (needs to run after DOM is ready) - processedCode = insertAfterHead(code, `\n<style id="zine-viewer-css">${css}</style>`); - processedCode = insertBeforeHeadClose(processedCode, `<script id="zine-viewer-js">${js}<\/script>\n`); - } else { - // No proper head found, wrap content - processedCode = `<!DOCTYPE html>\n<html>\n<head>\n<style id="zine-viewer-css">${css}</style>\n<script id="zine-viewer-js">${js}<\/script>\n</head>\n<body>\n${code}\n</body>\n</html>`; - } - - downloadHtmlFile(processedCode); + saveFileWithViewer(() => editorView.state.doc.toString()); }; window.loadFile = function() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.html,.htm'; - input.onchange = function(event) { - const file = event.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = function(e) { - editorView.dispatch({ - changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result } - }); - saveToStorage(); - currentSpread = 0; - updateSpreadIndicator(); - updatePreview(); - }; - reader.readAsText(file); - }; - input.click(); + loadFile(editorView, updatePreviewWrapper); }; -// Initialize CodeMirror -async function initializeCodeMirror() { - if (!window.CodeMirror) { - setTimeout(initializeCodeMirror, 100); - return; - } - - const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, history, closeBrackets, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers} = window.CodeMirror; - - const customPhrases = EditorState.phrases.of({ - "Find": "Find..." - }); - - const { content: savedContent, isBoilerplate } = await loadContent(); - loadEditorSettings(); - - lineNumbersCompartment = new Compartment(); - lineWrappingCompartment = new Compartment(); - - const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; - - const stateConfig = { - doc: savedContent, - extensions: [ - customPhrases, - history(), - search(), - closeBrackets(), - keymap.of([ - {key: "Mod-z", run: undo}, - {key: "Mod-y", run: redo}, - {key: "Mod-Shift-z", run: redo}, - {key: "Mod-o", run: () => { window.loadFile(); return true; }}, - {key: "Mod-s", run: () => { window.saveFile(); return true; }}, - {key: "Mod-e", run: () => { window.saveFileWithViewer(); return true; }}, - {key: "F1", run: () => { toggleLineNumbers(); return true; }}, - {key: "F2", run: () => { toggleLineWrapping(); return true; }}, - indentWithTab, - ...searchKeymap.filter(binding => binding.key !== "Mod-f"), - ...defaultKeymap - ]), - html(), - EditorView.inputHandler.of((view, from, to, text) => { - if (text !== '>') return false; - const before = view.state.doc.sliceString(Math.max(0, from - 20), from); - if (before.endsWith('<!')) { - const startPos = from - 2; - view.dispatch({ - changes: { from: startPos, to: from, insert: ZINE_BOILERPLATE }, - selection: { anchor: getBoilerplateCursorPos(startPos) } - }); - return true; - } - const match = before.match(/<(style|script)(\s[^>]*)?$/i); - if (!match) return false; - const tagName = match[1].toLowerCase(); - const closingTag = `</${tagName}>`; - view.dispatch({ - changes: { from, to, insert: '>' + closingTag }, - selection: { anchor: from + 1 } - }); - return true; - }), - githubDark, - indentUnit.of("\t"), - placeholder("Type \"<!>\" to insert zine outline..."), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - clearTimeout(updateTimer); - updateTimer = setTimeout(updatePreview, 600); - saveToStorage(); - } - }), - EditorView.contentAttributes.of({ - 'autocomplete': 'off', - 'autocorrect': 'off', - 'autocapitalize': 'off', - 'spellcheck': 'false' - }), - lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), - lineWrappingCompartment.of(initialLineWrappingExtension) - ] - }; - - editorView = new EditorView({ - state: EditorState.create(stateConfig), - parent: document.getElementById('editor') - }); - - if (isBoilerplate) { - editorView.dispatch({ - selection: { anchor: getBoilerplateCursorPos() } - }); - } +// Initialize editor +async function initializeEditor() { + editorView = await initializeCodeMirror(saveToStorage, updatePreviewFromContent); // Initialize spread navigation - document.getElementById('prevSpread').addEventListener('click', () => navigateSpread(-1)); - document.getElementById('nextSpread').addEventListener('click', () => navigateSpread(1)); - updateSpreadIndicator(); + initializeSpreadNav((delta) => navigateSpread(delta, updatePreviewWrapper)); - // Focus preview when clicking anywhere in the preview pane (for keyboard nav) - previewPane.addEventListener('click', (e) => { - // Don't steal focus if clicking nav buttons - if (!e.target.closest('.spread-nav button')) { - preview.focus(); - } - }); + // Setup preview pane + setupPreviewPane(updatePreviewWrapper); // Initial preview - updatePreview(); + updatePreviewWrapper(); // Track editor focus editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; }); editorView.contentDOM.addEventListener('blur', () => { isEditorFocused = false; if (isMobileDevice()) { - exitMobileKeyboardMode(); + exitMobileKeyboardMode(editorView, updatePreviewWrapper); } }); @@ -969,7 +72,7 @@ async function initializeCodeMirror() { // Keyboard shortcuts (only when editor not focused, since CodeMirror handles its own) document.addEventListener('keydown', function(e) { - // Skip if editor is focused - CodeMirror handles these + const {closeSearchPanel, openSearchPanel} = window.CodeMirror; const editorHasFocus = editorView.hasFocus; if ((e.metaKey || e.ctrlKey) && e.key === 'f') { @@ -985,88 +88,26 @@ async function initializeCodeMirror() { if ((e.metaKey || e.ctrlKey) && e.key === 'p') { e.preventDefault(); try { - preview.contentWindow.print(); + document.getElementById('preview').contentWindow.print(); } catch (err) { window.print(); } } }); - // Disable browser autocomplete on search panel inputs - const editorElement = document.getElementById('editor'); - const searchInputObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node.nodeType === Node.ELEMENT_NODE) { - const searchInputs = node.querySelectorAll?.('.cm-search input[name="search"], .cm-search input[name="replace"]'); - searchInputs?.forEach(input => input.setAttribute('autocomplete', 'off')); - } - } - } - }); - searchInputObserver.observe(editorElement, { childList: true, subtree: true }); -} + // Initialize mobile keyboard handling + initializeMobileKeyboard( + () => isEditorFocused, + () => exitMobileKeyboardMode(editorView, updatePreviewWrapper) + ); -// Mobile keyboard detection -function isMobileDevice() { - return window.matchMedia("(pointer: coarse), (pointer: none)").matches; -} - -function exitMobileKeyboardMode() { - if (!document.body.classList.contains('mobile-keyboard-open')) return; - - editorPane.style.opacity = '0'; - document.body.classList.remove('mobile-keyboard-open'); - - requestAnimationFrame(() => requestAnimationFrame(() => { - if (editorView) { - const pos = editorView.state.selection.main.head; - const lineBlock = editorView.lineBlockAt(pos); - const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); - editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); + // Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px) + window.addEventListener('resize', function() { + if (getIsFullscreen() && window.innerHeight <= 200) { + toggleFullscreen(updatePreviewWrapper); } - editorPane.style.opacity = ''; - updatePreview(); - })); -} - -function updateViewportVariables() { - const vv = window.visualViewport; - if (vv) { - document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`); - document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`); - } -} - -function handleViewportChange() { - if (!isMobileDevice()) return; - - const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; - const heightDifference = initialViewportHeight - currentHeight; - const isKeyboardOpen = heightDifference > 150; - - updateViewportVariables(); - - if (isKeyboardOpen && isEditorFocused) { - document.body.classList.add('mobile-keyboard-open'); - } else if (!isKeyboardOpen) { - exitMobileKeyboardMode(); - } -} - -if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleViewportChange); - window.visualViewport.addEventListener('scroll', updateViewportVariables); -} else { - window.addEventListener('resize', handleViewportChange); + }); } -// Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px) -window.addEventListener('resize', function() { - if (isFullscreen && window.innerHeight <= 200) { - toggleFullscreen(); - } -}); - // Initialize -initializeCodeMirror(); +initializeEditor(); diff --git a/resources/file-operations.js b/resources/file-operations.js @@ -0,0 +1,63 @@ +import { insertAfterHead, insertBeforeHeadClose } from './html-utils.js'; +import { generateViewerCode } from './viewer-generator.js'; +import { saveToStorage } from './storage.js'; +import { setCurrentSpread, updateSpreadIndicator } from './spread-navigation.js'; + +// File operations +function downloadHtmlFile(content, filename = 'my-zine.html') { + const blob = new Blob([content], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +export function saveFile(getEditorContent) { + downloadHtmlFile(getEditorContent()); +} + +export function saveFileWithViewer(getEditorContent) { + const code = getEditorContent(); + const { css, js } = generateViewerCode(); + + // Check if code has proper head tags + const hasHead = /<head[^>]*>/i.test(code) && /<\/head>/i.test(code); + + let processedCode; + if (hasHead) { + // Inject viewer CSS at START of head (so user styles take precedence for page content) + // Inject viewer JS at END of head (needs to run after DOM is ready) + processedCode = insertAfterHead(code, `\n<style id="zine-viewer-css">${css}</style>`); + processedCode = insertBeforeHeadClose(processedCode, `<script id="zine-viewer-js">${js}<\/script>\n`); + } else { + // No proper head found, wrap content + processedCode = `<!DOCTYPE html>\n<html>\n<head>\n<style id="zine-viewer-css">${css}</style>\n<script id="zine-viewer-js">${js}<\/script>\n</head>\n<body>\n${code}\n</body>\n</html>`; + } + + downloadHtmlFile(processedCode); +} + +export function loadFile(editorView, updatePreviewCallback) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.html,.htm'; + input.onchange = function(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + editorView.dispatch({ + changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result } + }); + saveToStorage(e.target.result); + setCurrentSpread(0); + updateSpreadIndicator(); + updatePreviewCallback(); + }; + reader.readAsText(file); + }; + input.click(); +} diff --git a/resources/html-utils.js b/resources/html-utils.js @@ -0,0 +1,67 @@ +// Insert content right after <head> tag in HTML string +export function insertAfterHead(html, content) { + const headMatch = html.match(/<head[^>]*>/i); + if (headMatch) { + const insertPos = html.indexOf(headMatch[0]) + headMatch[0].length; + return html.slice(0, insertPos) + content + html.slice(insertPos); + } + return html; +} + +// Insert content right before </head> tag in HTML string +export function insertBeforeHeadClose(html, content) { + const headEndMatch = html.match(/<\/head>/i); + if (headEndMatch) { + const insertPos = html.indexOf(headEndMatch[0]); + return html.slice(0, insertPos) + content + html.slice(insertPos); + } + return html; +} + +// Extract title and favicon from user's HTML +export function extractTitleAndFavicon(htmlCode) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlCode, 'text/html'); + const titleElement = doc.querySelector('title'); + const title = titleElement ? titleElement.textContent.trim() : null; + const faviconSelectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]' + ]; + let favicon = null; + for (const selector of faviconSelectors) { + const faviconElement = doc.querySelector(selector); + if (faviconElement && faviconElement.getAttribute('href')) { + favicon = faviconElement.getAttribute('href'); + break; + } + } + return { title, favicon }; +} + +export function updateMainPageTitleAndFavicon(title, favicon) { + if (title) { + document.title = title; + } else { + document.title = 'zine.html'; + } + let faviconLink = document.querySelector('link[rel="icon"]'); + if (!faviconLink) { + faviconLink = document.createElement('link'); + faviconLink.rel = 'icon'; + document.head.appendChild(faviconLink); + } + if (favicon) { + faviconLink.href = favicon; + } else { + faviconLink.href = 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖨️</text></svg>'; + } +} + +// Create an empty placeholder div for single-page spreads +export function createEmptyPlaceholder(doc) { + const empty = doc.createElement('div'); + empty.className = 'zine-empty'; + return empty; +} diff --git a/resources/mobile-keyboard.js b/resources/mobile-keyboard.js @@ -0,0 +1,60 @@ +let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; + +// Mobile keyboard detection +export function isMobileDevice() { + return window.matchMedia("(pointer: coarse), (pointer: none)").matches; +} + +export function exitMobileKeyboardMode(editorView, updatePreviewCallback) { + const editorPane = document.querySelector('.editor-pane'); + if (!document.body.classList.contains('mobile-keyboard-open')) return; + + editorPane.style.opacity = '0'; + document.body.classList.remove('mobile-keyboard-open'); + + requestAnimationFrame(() => requestAnimationFrame(() => { + if (editorView) { + const pos = editorView.state.selection.main.head; + const lineBlock = editorView.lineBlockAt(pos); + const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); + editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); + } + editorPane.style.opacity = ''; + updatePreviewCallback(); + })); +} + +function updateViewportVariables() { + const vv = window.visualViewport; + if (vv) { + document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`); + document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`); + } +} + +function handleViewportChange(isEditorFocused, exitCallback) { + if (!isMobileDevice()) return; + + const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; + const heightDifference = initialViewportHeight - currentHeight; + const isKeyboardOpen = heightDifference > 150; + + updateViewportVariables(); + + if (isKeyboardOpen && isEditorFocused) { + document.body.classList.add('mobile-keyboard-open'); + } else if (!isKeyboardOpen) { + exitCallback(); + } +} + +export function initializeMobileKeyboard(getIsEditorFocused, exitCallback) { + const handleChange = () => handleViewportChange(getIsEditorFocused(), exitCallback); + + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', handleChange); + window.visualViewport.addEventListener('scroll', updateViewportVariables); + } else { + window.addEventListener('resize', handleChange); + } +} diff --git a/resources/preview-manager.js b/resources/preview-manager.js @@ -0,0 +1,121 @@ +import { insertAfterHead } from './html-utils.js'; +import { extractTitleAndFavicon, updateMainPageTitleAndFavicon } from './html-utils.js'; +import { generateSpreadCSS } 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'; + +export function updatePreview(editorView, isEditorFocused) { + const preview = document.getElementById('preview'); + + if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) { + return; + } + + const code = editorView.state.doc.toString(); + + // Extract and update title and favicon from user's HTML + const { title, favicon } = extractTitleAndFavicon(code); + updateMainPageTitleAndFavicon(title, favicon); + + let scrollX = 0, scrollY = 0; + try { + if (preview.contentWindow?.scrollX !== undefined) { + scrollX = preview.contentWindow.scrollX; + scrollY = preview.contentWindow.scrollY; + } + } catch (e) {} + + // Inject spread CSS right after <head> so user styles come last and take precedence + const currentSpread = getCurrentSpread(); + const spreadCSS = 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>'; + + const onLoad = () => { + try { + const doc = preview.contentDocument; + if (!doc) return; + + // Add CSS for overscroll and fullscreen button + const style = doc.createElement('style'); + style.textContent = '* { overscroll-behavior: none !important; } .iframe-fullscreen-toggle { position: fixed; top: 5px; right: 5px; z-index: 10000; background: rgba(0, 0, 0, 0.2); color: white; border: none; border-radius: 4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -webkit-tap-highlight-color: transparent; outline: none; user-select: none; } .iframe-fullscreen-toggle svg { filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); opacity: 0.8; transition: opacity 0.2s; } @media (hover: hover) and (pointer: fine) { .iframe-fullscreen-toggle:hover { background: rgba(0, 0, 0, 0.35); } .iframe-fullscreen-toggle:hover svg { opacity: 1; } }'; + doc.head.appendChild(style); + + // Create fullscreen button + const existingButton = doc.getElementById('fullscreenToggle'); + if (existingButton) existingButton.remove(); + + const button = doc.createElement('button'); + button.id = 'fullscreenToggle'; + button.className = 'iframe-fullscreen-toggle'; + button.title = 'Toggle fullscreen'; + button.innerHTML = '<svg width="20" height="20" viewBox="0 0 14 14" fill="white"><path d="M 7,14 H 5 v 5 h 5 V 17 H 7 Z M 5,10 H 7 V 7 h 3 V 5 H 5 Z m 12,7 h -3 v 2 h 5 V 14 H 17 Z M 14,5 v 2 h 3 v 3 h 2 V 5 Z" transform="translate(-5,-5)"/></svg>'; + button.addEventListener('click', function() { + parent.postMessage('toggleFullscreen', '*'); + }); + + if (doc.body) { + doc.body.appendChild(button); + + // Create container for the spread and wrap visible pages + const spread = SPREADS[currentSpread]; + const container = doc.createElement('div'); + container.className = 'zine-spread-container'; + + setupSpreadLayout(container, spread, doc); + doc.body.appendChild(container); + + // Scale container to fit viewport + const scaleToFit = () => scaleSpreadToFit(container, doc); + scaleToFit(); + doc.defaultView.addEventListener('resize', scaleToFit); + } + + // Add keyboard listener for navigation and shortcuts + doc.addEventListener('keydown', function(e) { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + parent.postMessage('prevSpread', '*'); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + parent.postMessage('nextSpread', '*'); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + e.preventDefault(); + window.print(); + } else if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + parent.postMessage('saveFile', '*'); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'e') { + e.preventDefault(); + parent.postMessage('saveFileWithViewer', '*'); + } + }); + + setTimeout(() => { + try { + preview.contentWindow?.scrollTo(scrollX, scrollY); + } catch (e) {} + }, 10); + } catch (e) {} + + preview.removeEventListener('load', onLoad); + }; + preview.addEventListener('load', onLoad); +} + +// Setup preview pane interactions +export function setupPreviewPane(updatePreviewCallback) { + const previewPane = document.querySelector('.preview-pane'); + const preview = document.getElementById('preview'); + + // Focus preview when clicking anywhere in the preview pane (for keyboard nav) + previewPane.addEventListener('click', (e) => { + // Don't steal focus if clicking nav buttons + if (!e.target.closest('.spread-nav button')) { + preview.focus(); + } + }); +} diff --git a/resources/spread-layout.js b/resources/spread-layout.js @@ -0,0 +1,35 @@ +import { SPREADS } from './constants.js'; +import { createEmptyPlaceholder } from './html-utils.js'; + +// Common logic for displaying a spread (used by both editor preview and standalone viewer) +export function setupSpreadLayout(container, spread, doc) { + // Add empty placeholder before right page if no left page + if (!spread.left && spread.right) { + container.appendChild(createEmptyPlaceholder(doc)); + } + + // Move visible pages into container + const visiblePages = [spread.left, spread.right].filter(Boolean); + visiblePages.forEach(id => { + const page = doc.getElementById(id); + if (page) container.appendChild(page); + }); + + // Add empty placeholder after left page if no right page + if (spread.left && !spread.right) { + container.appendChild(createEmptyPlaceholder(doc)); + } +} + +// Scale spread container to fit viewport +export function scaleSpreadToFit(container, doc, bottomPadding = 0) { + const vw = doc.documentElement.clientWidth; + const vh = doc.documentElement.clientHeight - bottomPadding; + const spreadWidthPx = container.offsetWidth; + const spreadHeightPx = container.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})`; +} diff --git a/resources/spread-navigation.js b/resources/spread-navigation.js @@ -0,0 +1,87 @@ +import { SPREADS } from './constants.js'; + +// State +let currentSpread = 0; +let isFullscreen = false; + +// Get current spread index +export function getCurrentSpread() { + return currentSpread; +} + +// Set current spread index +export function setCurrentSpread(index) { + currentSpread = index; +} + +// Get fullscreen state +export function getIsFullscreen() { + return isFullscreen; +} + +// Set fullscreen state +export function setIsFullscreen(value) { + isFullscreen = value; +} + +// Update spread indicator and button states +export function updateSpreadIndicator() { + const indicator = document.getElementById('spreadIndicator'); + const prevBtn = document.getElementById('prevSpread'); + const nextBtn = document.getElementById('nextSpread'); + + indicator.textContent = SPREADS[currentSpread].label; + prevBtn.disabled = currentSpread === 0; + nextBtn.disabled = currentSpread === SPREADS.length - 1; +} + +// Navigate between spreads +export function navigateSpread(delta, updatePreviewCallback) { + const newSpread = currentSpread + delta; + if (newSpread >= 0 && newSpread < SPREADS.length) { + currentSpread = newSpread; + updateSpreadIndicator(); + updatePreviewCallback(); + // Refocus preview so keyboard navigation continues to work + document.getElementById('preview').focus(); + } +} + +// Fullscreen toggle +export function toggleFullscreen(updatePreviewCallback) { + isFullscreen = !isFullscreen; + const editorPane = document.querySelector('.editor-pane'); + const previewPane = document.querySelector('.preview-pane'); + const preview = document.getElementById('preview'); + + if (isFullscreen) { + previewPane.classList.add('fullscreen'); + editorPane.classList.add('hidden'); + preview.style.height = window.innerHeight + 'px'; + } else { + previewPane.classList.remove('fullscreen'); + editorPane.classList.remove('hidden'); + preview.style.height = ''; + } + + try { + const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); + if (toggleButton) { + toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; + } + } catch (e) {} + + // Trigger resize after layout updates so scaleToFit() recalculates + requestAnimationFrame(() => { + try { + preview.contentWindow.dispatchEvent(new Event('resize')); + } catch (e) {} + }); +} + +// Initialize spread navigation UI +export function initializeSpreadNav(navigateCallback) { + document.getElementById('prevSpread').addEventListener('click', () => navigateCallback(-1)); + document.getElementById('nextSpread').addEventListener('click', () => navigateCallback(1)); + updateSpreadIndicator(); +} diff --git a/resources/storage.js b/resources/storage.js @@ -0,0 +1,38 @@ +import { STORAGE_KEY, ZINE_BOILERPLATE } from './constants.js'; + +export function saveToStorage(content) { + try { + localStorage.setItem(STORAGE_KEY, content); + } catch (e) { + console.warn('Could not save to localStorage:', e); + } +} + +export async function loadContent() { + let content = ZINE_BOILERPLATE; + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && saved.trim()) { + content = saved; + } + } catch (e) { + console.warn('Could not load from localStorage:', e); + } + return { content, isBoilerplate: content === ZINE_BOILERPLATE }; +} + +export function loadEditorSettings() { + try { + const showLineNumbers = localStorage.getItem('zine-editor-line-numbers') === 'true'; + const enableLineWrapping = localStorage.getItem('zine-editor-line-wrapping') !== 'false'; + return { showLineNumbers, enableLineWrapping }; + } catch (e) { + return { showLineNumbers: false, enableLineWrapping: true }; + } +} + +export function saveEditorSetting(key, value) { + try { + localStorage.setItem(key, value.toString()); + } catch (e) {} +} diff --git a/resources/viewer-generator.js b/resources/viewer-generator.js @@ -0,0 +1,201 @@ +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'; + +// Generate standalone viewer CSS and JS (matches preview pane rendering) +export function generateViewerCode() { + const css = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + + @media screen { + html, body { + height: 100% !important; + overflow: hidden !important; + } + body { + background: linear-gradient(to top, #2a2a2a, #3a3a3a) !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + padding-bottom: ${NAV_HEIGHT}px !important; + } + body > *:not(.zine-spread-container):not(.zine-nav) { + display: none !important; + } + ${ZINE_PAGE_CSS} + .zine-spread-container { + display: flex !important; + position: relative; + } + .zine-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: ${NAV_HEIGHT}px; + ${NAV_BUTTON_CSS} + background: transparent; + } + .zine-nav button { + ${NAV_BUTTON_STYLES} + } + .zine-nav button:hover:not(:disabled) { background: rgba(255,255,255,0.25); } + .zine-nav button:disabled { opacity: 0.3; cursor: default; } + .zine-nav span { color: white; font-size: 14px; min-width: 100px; text-align: center; font-family: monospace; } + } + + @media print { + ${ZINE_PRINT_CSS} + } + + /* Hide content when viewport is very short */ + @media screen and (max-height: 200px) { + .zine-spread-container, .zine-nav { + display: none !important; + } + } + `; + + // Serialize SPREADS array for the viewer + const spreadsJson = JSON.stringify(SPREADS); + const pageIdsJson = JSON.stringify(PAGE_IDS); + + const js = ` + const SPREADS = ${spreadsJson}; + const PAGE_IDS = ${pageIdsJson}; + const NAV_HEIGHT = ${NAV_HEIGHT}; + let currentSpread = 0; + let container; + + function scaleToFit() { + if (!container) return; + const vw = document.documentElement.clientWidth; + const vh = document.documentElement.clientHeight - NAV_HEIGHT; + const spreadWidthPx = container.offsetWidth; + const spreadHeightPx = container.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 + ')'; + } + + function showSpread(index) { + const spread = SPREADS[index]; + const visible = [spread.left, spread.right].filter(Boolean); + + // Move existing pages back to body before clearing container + PAGE_IDS.forEach(id => { + const page = document.getElementById(id); + if (page) { + page.style.display = 'none'; + document.body.appendChild(page); + } + }); + + container.innerHTML = ''; + + if (!spread.left && spread.right) { + const empty = document.createElement('div'); + empty.className = 'zine-empty'; + container.appendChild(empty); + } + + visible.forEach(id => { + const el = document.getElementById(id); + if (el) { + el.style.display = 'block'; + container.appendChild(el); + } + }); + + if (spread.left && !spread.right) { + const empty = document.createElement('div'); + empty.className = 'zine-empty'; + container.appendChild(empty); + } + + document.getElementById('zine-indicator').textContent = spread.label; + document.getElementById('zine-prev').disabled = index === 0; + document.getElementById('zine-next').disabled = index === SPREADS.length - 1; + scaleToFit(); + } + + function navigate(delta) { + const newIndex = currentSpread + delta; + if (newIndex >= 0 && newIndex < SPREADS.length) { + currentSpread = newIndex; + showSpread(currentSpread); + } + } + + document.addEventListener('DOMContentLoaded', () => { + container = document.createElement('div'); + container.className = 'zine-spread-container'; + document.body.appendChild(container); + + const nav = document.createElement('div'); + nav.className = 'zine-nav'; + nav.innerHTML = '<button id="zine-prev" title="Previous spread">\\u2190</button><span id="zine-indicator"></span><button id="zine-next" title="Next spread">\\u2192</button>'; + document.body.appendChild(nav); + + document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); + document.getElementById('zine-next').addEventListener('click', () => navigate(1)); + + document.addEventListener('keydown', e => { + if (e.key === 'ArrowLeft') navigate(-1); + else if (e.key === 'ArrowRight') navigate(1); + else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + e.preventDefault(); + window.print(); + } + else if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'e')) { + e.preventDefault(); + // Re-download this viewer file (clean version without dynamic elements) + // Move pages back to body and remove dynamic elements temporarily + PAGE_IDS.forEach(id => { + const page = document.getElementById(id); + if (page) { + page.style.display = ''; + document.body.appendChild(page); + } + }); + const tempContainer = document.querySelector('.zine-spread-container'); + const tempNav = document.querySelector('.zine-nav'); + if (tempContainer) tempContainer.remove(); + if (tempNav) tempNav.remove(); + + const html = '<!DOCTYPE html>' + document.documentElement.outerHTML; + + // Restore dynamic elements + const newContainer = document.createElement('div'); + newContainer.className = 'zine-spread-container'; + document.body.appendChild(newContainer); + container = newContainer; + + const newNav = document.createElement('div'); + newNav.className = 'zine-nav'; + newNav.innerHTML = '<button id="zine-prev" title="Previous spread">\\u2190</button><span id="zine-indicator"></span><button id="zine-next" title="Next spread">\\u2192</button>'; + document.body.appendChild(newNav); + document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); + document.getElementById('zine-next').addEventListener('click', () => navigate(1)); + + showSpread(currentSpread); + + const blob = new Blob([html], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'my-zine.html'; + a.click(); + URL.revokeObjectURL(url); + } + }); + + window.addEventListener('resize', scaleToFit); + + showSpread(0); + }); + `; + + return { css, js }; +} diff --git a/resources/zine-styles.js b/resources/zine-styles.js @@ -0,0 +1,138 @@ +import { PAGE_WIDTH_IN, PAGE_HEIGHT_IN, PAGE_PADDING_IN, ALL_PAGES_SELECTOR, RIGHT_PAGES_SELECTOR, LEFT_PAGES_SELECTOR, SPREADS } from './constants.js'; + +// Shared print CSS for zine layout (used by both editor preview and exported viewer) +export const ZINE_PRINT_CSS = ` + @page { size: 8.5in 11in portrait; margin: 0; } + html, body { width: 8.5in; height: 11in; } + body { + display: grid !important; + grid-template-columns: repeat(4, ${PAGE_WIDTH_IN}in); + grid-template-rows: repeat(2, ${PAGE_HEIGHT_IN}in); + transform: rotate(90deg); + transform-origin: center center; + position: absolute; + top: calc(50% - ${PAGE_HEIGHT_IN}in); + left: calc(50% - 5.5in); + width: 11in; + height: 8.5in; + } + ${ALL_PAGES_SELECTOR} { + display: block !important; + width: ${PAGE_WIDTH_IN}in !important; + height: ${PAGE_HEIGHT_IN}in !important; + padding: ${PAGE_PADDING_IN}in; + background: white; + overflow: hidden; + overflow-wrap: break-word; + aspect-ratio: auto !important; + box-shadow: none !important; + transform: none; + } + #front-cover { grid-row: 2; grid-column: 2; } + #page1 { grid-row: 2; grid-column: 3; } + #page2 { grid-row: 2; grid-column: 4; } + #page3 { grid-row: 1; grid-column: 4; transform: rotate(180deg); } + #page4 { grid-row: 1; grid-column: 3; transform: rotate(180deg); } + #page5 { grid-row: 1; grid-column: 2; transform: rotate(180deg); } + #page6 { grid-row: 1; grid-column: 1; transform: rotate(180deg); } + #back-cover { grid-row: 2; grid-column: 1; } + .zine-nav, .zine-empty, .spread-nav, .iframe-fullscreen-toggle, .zine-spread-container { display: contents !important; } + .zine-spread-container { transform: none !important; } + a { color: black; } +`; + +// Shared CSS for zine pages (used by both editor preview and exported viewer) +export const ZINE_PAGE_CSS = ` + ${ALL_PAGES_SELECTOR} { + display: none; + width: ${PAGE_WIDTH_IN}in; + height: ${PAGE_HEIGHT_IN}in; + flex-shrink: 0; + padding: ${PAGE_PADDING_IN}in; + overflow: hidden; + overflow-wrap: break-word; + } + .page { + background: white; + } + .zine-spread-container { + display: flex; + transform-origin: center center; + } + ${RIGHT_PAGES_SELECTOR} { + box-shadow: inset 4px 0 1.3px -3px rgba(0, 0, 0, 0.09), inset 8px 0 6px -6px rgba(0, 0, 0, 0.15); + } + ${LEFT_PAGES_SELECTOR} { + box-shadow: inset -4px 0 1.5px -3px rgba(0, 0, 0, 0.09), inset -8px 0 6px -6px rgba(0, 0, 0, 0.15); + } + .zine-empty { + width: ${PAGE_WIDTH_IN}in; + height: ${PAGE_HEIGHT_IN}in; + flex-shrink: 0; + border: 1px dashed #666; + } + .zine-empty:first-child { + border-right: none; + } + .zine-empty:last-child { + border-left: none; + } +`; + +// Generate CSS to show only the current spread +export function generateSpreadCSS(spreadIndex) { + const spread = SPREADS[spreadIndex]; + const visiblePages = [spread.left, spread.right].filter(Boolean); + + return ` + /* Base styles for all views */ + * { margin: 0; padding: 0; box-sizing: border-box; } + + /* Screen view: show spread at actual size, then scale to fit */ + @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-spread-container { + display: flex !important; + position: relative; + } + + ${ALL_PAGES_SELECTOR} { + display: none !important; + width: ${PAGE_WIDTH_IN}in !important; + height: ${PAGE_HEIGHT_IN}in !important; + } + + /* Show visible pages */ + ${visiblePages.map(id => `#${id}`).join(', ')} { + display: block !important; + } + + .zine-empty { + display: block !important; + width: ${PAGE_WIDTH_IN}in !important; + height: ${PAGE_HEIGHT_IN}in !important; + } + } + + /* Print view: 8-page grid for mini-zine folding */ + @media print { + ${ZINE_PRINT_CSS} + } + `; +}