refactor; break up editor.js Hunter Monday, February 2nd, 2026 at 5:24 PM
1 +import { ZINE_BOILERPLATE, getBoilerplateCursorPos } from './constants.js'; 2 +import { loadContent, loadEditorSettings, saveEditorSetting } from './storage.js'; 3 + 4 +let lineNumbersCompartment; 5 +let lineWrappingCompartment; 6 +let showLineNumbers = false; 7 +let enableLineWrapping = false; 8 + 9 +export function toggleLineNumbers(editorView) { 10 + const {lineNumbers} = window.CodeMirror; 11 + showLineNumbers = !showLineNumbers; 12 + saveEditorSetting('zine-editor-line-numbers', showLineNumbers); 13 + editorView.dispatch({ 14 + effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : []) 15 + }); 16 +} 17 + 18 +function createLineWrappingExtension() { 19 + const {EditorView, Decoration} = window.CodeMirror; 20 + 21 + return [ 22 + EditorView.lineWrapping, 23 + EditorView.decorations.of((view) => { 24 + const decorations = []; 25 + for (let {from, to} of view.visibleRanges) { 26 + for (let pos = from; pos <= to;) { 27 + const line = view.state.doc.lineAt(pos); 28 + const lineText = line.text; 29 + let indentChars = 0; 30 + for (let i = 0; i < lineText.length; i++) { 31 + if (lineText[i] === '\t') { 32 + indentChars += 2; 33 + } else if (lineText[i] === ' ') { 34 + indentChars += 1; 35 + } else { 36 + break; 37 + } 38 + } 39 + if (indentChars > 0) { 40 + const indentDecoration = Decoration.line({ 41 + attributes: { 42 + style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);` 43 + } 44 + }); 45 + decorations.push(indentDecoration.range(line.from)); 46 + } 47 + pos = line.to + 1; 48 + } 49 + } 50 + return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none; 51 + }), 52 + ]; 53 +} 54 + 55 +export function toggleLineWrapping(editorView) { 56 + enableLineWrapping = !enableLineWrapping; 57 + saveEditorSetting('zine-editor-line-wrapping', enableLineWrapping); 58 + const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; 59 + editorView.dispatch({ 60 + effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) 61 + }); 62 +} 63 + 64 +// Initialize CodeMirror 65 +export async function initializeCodeMirror(saveToStorageCallback, updatePreviewCallback) { 66 + if (!window.CodeMirror) { 67 + setTimeout(() => initializeCodeMirror(saveToStorageCallback, updatePreviewCallback), 100); 68 + return; 69 + } 70 + 71 + const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, history, closeBrackets, search, searchKeymap, lineNumbers} = window.CodeMirror; 72 + 73 + const customPhrases = EditorState.phrases.of({ 74 + "Find": "Find..." 75 + }); 76 + 77 + const { content: savedContent, isBoilerplate } = await loadContent(); 78 + const settings = loadEditorSettings(); 79 + showLineNumbers = settings.showLineNumbers; 80 + enableLineWrapping = settings.enableLineWrapping; 81 + 82 + lineNumbersCompartment = new Compartment(); 83 + lineWrappingCompartment = new Compartment(); 84 + 85 + const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; 86 + 87 + const stateConfig = { 88 + doc: savedContent, 89 + extensions: [ 90 + customPhrases, 91 + history(), 92 + search(), 93 + closeBrackets(), 94 + keymap.of([ 95 + {key: "Mod-z", run: undo}, 96 + {key: "Mod-y", run: redo}, 97 + {key: "Mod-Shift-z", run: redo}, 98 + {key: "Mod-o", run: () => { window.loadFile(); return true; }}, 99 + {key: "Mod-s", run: () => { window.saveFile(); return true; }}, 100 + {key: "Mod-e", run: () => { window.saveFileWithViewer(); return true; }}, 101 + {key: "F1", run: (view) => { toggleLineNumbers(view); return true; }}, 102 + {key: "F2", run: (view) => { toggleLineWrapping(view); return true; }}, 103 + indentWithTab, 104 + ...searchKeymap.filter(binding => binding.key !== "Mod-f"), 105 + ...defaultKeymap 106 + ]), 107 + html(), 108 + EditorView.inputHandler.of((view, from, to, text) => { 109 + if (text !== '>') return false; 110 + const before = view.state.doc.sliceString(Math.max(0, from - 20), from); 111 + if (before.endsWith('<!')) { 112 + const startPos = from - 2; 113 + view.dispatch({ 114 + changes: { from: startPos, to: from, insert: ZINE_BOILERPLATE }, 115 + selection: { anchor: getBoilerplateCursorPos(startPos) } 116 + }); 117 + return true; 118 + } 119 + const match = before.match(/<(style|script)(\s[^>]*)?$/i); 120 + if (!match) return false; 121 + const tagName = match[1].toLowerCase(); 122 + const closingTag = `</${tagName}>`; 123 + view.dispatch({ 124 + changes: { from, to, insert: '>' + closingTag }, 125 + selection: { anchor: from + 1 } 126 + }); 127 + return true; 128 + }), 129 + githubDark, 130 + indentUnit.of("\t"), 131 + placeholder("Type \"<!>\" to insert zine outline..."), 132 + EditorView.updateListener.of((update) => { 133 + if (update.docChanged) { 134 + const content = update.state.doc.toString(); 135 + clearTimeout(window.updateTimer); 136 + window.updateTimer = setTimeout(() => updatePreviewCallback(content), 600); 137 + saveToStorageCallback(content); 138 + } 139 + }), 140 + EditorView.contentAttributes.of({ 141 + 'autocomplete': 'off', 142 + 'autocorrect': 'off', 143 + 'autocapitalize': 'off', 144 + 'spellcheck': 'false' 145 + }), 146 + lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), 147 + lineWrappingCompartment.of(initialLineWrappingExtension) 148 + ] 149 + }; 150 + 151 + const editorView = new EditorView({ 152 + state: EditorState.create(stateConfig), 153 + parent: document.getElementById('editor') 154 + }); 155 + 156 + if (isBoilerplate) { 157 + editorView.dispatch({ 158 + selection: { anchor: getBoilerplateCursorPos() } 159 + }); 160 + } 161 + 162 + // Disable browser autocomplete on search panel inputs 163 + const editorElement = document.getElementById('editor'); 164 + const searchInputObserver = new MutationObserver((mutations) => { 165 + for (const mutation of mutations) { 166 + for (const node of mutation.addedNodes) { 167 + if (node.nodeType === Node.ELEMENT_NODE) { 168 + const searchInputs = node.querySelectorAll?.('.cm-search input[name="search"], .cm-search input[name="replace"]'); 169 + searchInputs?.forEach(input => input.setAttribute('autocomplete', 'off')); 170 + } 171 + } 172 + } 173 + }); 174 + searchInputObserver.observe(editorElement, { childList: true, subtree: true }); 175 + 176 + return editorView; 177 +}
1 +// Zine page dimensions in inches 2 +export const PAGE_WIDTH_IN = 2.75; 3 +export const PAGE_HEIGHT_IN = 4.25; 4 +export const PAGE_PADDING_IN = 0.2; 5 + 6 +// All page IDs for selectors 7 +export const PAGE_IDS = ['front-cover', 'page1', 'page2', 'page3', 'page4', 'page5', 'page6', 'back-cover']; 8 +export const ALL_PAGES_SELECTOR = PAGE_IDS.map(id => `#${id}`).join(', '); 9 +export const RIGHT_PAGES_SELECTOR = '#front-cover, #page2, #page4, #page6'; 10 +export const LEFT_PAGES_SELECTOR = '#back-cover, #page1, #page3, #page5'; 11 + 12 +// Spread definitions 13 +export const SPREADS = [ 14 + { left: null, right: 'front-cover', label: 'Front Cover' }, 15 + { left: 'page1', right: 'page2', label: 'Pages 1-2' }, 16 + { left: 'page3', right: 'page4', label: 'Pages 3-4' }, 17 + { left: 'page5', right: 'page6', label: 'Pages 5-6' }, 18 + { left: 'back-cover', right: null, label: 'Back Cover' } 19 +]; 20 + 21 +// Height of nav bar for layout calculations 22 +export const NAV_HEIGHT = 60; 23 + 24 +// Shared navigation button CSS (used by both editor spread-nav and exported viewer zine-nav) 25 +export const NAV_BUTTON_CSS = ` 26 + display: flex; 27 + align-items: center; 28 + justify-content: center; 29 + gap: 16px; 30 + padding: 12px 20px; 31 +`; 32 + 33 +export const NAV_BUTTON_STYLES = ` 34 + background: rgba(255,255,255,0.15); 35 + color: white; 36 + border: none; 37 + border-radius: 50%; 38 + width: 36px; 39 + height: 36px; 40 + cursor: pointer; 41 + font-size: 18px; 42 + display: flex; 43 + align-items: center; 44 + justify-content: center; 45 +`; 46 + 47 +// Zine HTML boilerplate template 48 +export const ZINE_BOILERPLATE = `<!DOCTYPE html> 49 +<html> 50 + <head> 51 + <style> 52 + 53 + </style> 54 + </head> 55 + <body> 56 + <div class="page" id="front-cover"> 57 + 58 + </div> 59 + <div class="page" id="page1"> 60 + 61 + </div> 62 + <div class="page" id="page2"> 63 + 64 + </div> 65 + <div class="page" id="page3"> 66 + 67 + </div> 68 + <div class="page" id="page4"> 69 + 70 + </div> 71 + <div class="page" id="page5"> 72 + 73 + </div> 74 + <div class="page" id="page6"> 75 + 76 + </div> 77 + <div class="page" id="back-cover"> 78 + 79 + </div> 80 + </body> 81 +</html> 82 +`; 83 + 84 +// Get cursor position for boilerplate (inside front-cover div) 85 +export function getBoilerplateCursorPos(startPos = 0) { 86 + return startPos + ZINE_BOILERPLATE.indexOf('<div class="page" id="front-cover">') + 39; 87 +} 88 + 89 +// Storage key 90 +export const STORAGE_KEY = 'zine-editor-content';
-let updateTimer; 1 +import { saveToStorage } from './storage.js'; 2 +import { saveFile, saveFileWithViewer, loadFile } from './file-operations.js'; 3 +import { updatePreview, setupPreviewPane } from './preview-manager.js'; 4 +import { navigateSpread, toggleFullscreen, initializeSpreadNav, getIsFullscreen } from './spread-navigation.js'; 5 +import { initializeCodeMirror } from './codemirror-config.js'; 6 +import { isMobileDevice, exitMobileKeyboardMode, initializeMobileKeyboard } from './mobile-keyboard.js'; 7 + 8 +// Global state 9 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; 10 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; - } 11 - .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; 12 +// Wrapper functions for updatePreview 13 +function updatePreviewWrapper() { 14 + updatePreview(editorView, isEditorFocused); 15 } 16 -// 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) {} - }); 17 +function updatePreviewFromContent() { 18 + updatePreviewWrapper(); 19 } 20 21 +// Message handler for iframe communication 22 window.addEventListener('message', function(event) { 23 if (event.data === 'toggleFullscreen') { - toggleFullscreen(); 24 + toggleFullscreen(updatePreviewWrapper); 25 } else if (event.data === 'prevSpread') { - navigateSpread(-1); 26 + navigateSpread(-1, updatePreviewWrapper); 27 } else if (event.data === 'nextSpread') { - navigateSpread(1); 28 + navigateSpread(1, updatePreviewWrapper); 29 } else if (event.data === 'saveFile') { 30 window.saveFile(); 31 } else if (event.data === 'saveFileWithViewer') { 33 } 34 }); 35 -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); -} - 36 +// Expose file operations to window for keyboard shortcuts 37 window.saveFile = function() { - downloadHtmlFile(editorView.state.doc.toString()); 38 + saveFile(() => editorView.state.doc.toString()); 39 }; 40 41 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); 42 + saveFileWithViewer(() => editorView.state.doc.toString()); 43 }; 44 45 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(); 46 + loadFile(editorView, updatePreviewWrapper); 47 }; 48 -// 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() } - }); - } 49 +// Initialize editor 50 +async function initializeEditor() { 51 + editorView = await initializeCodeMirror(saveToStorage, updatePreviewFromContent); 52 53 // Initialize spread navigation - document.getElementById('prevSpread').addEventListener('click', () => navigateSpread(-1)); - document.getElementById('nextSpread').addEventListener('click', () => navigateSpread(1)); - updateSpreadIndicator(); 54 + initializeSpreadNav((delta) => navigateSpread(delta, updatePreviewWrapper)); 55 - // 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(); - } - }); 56 + // Setup preview pane 57 + setupPreviewPane(updatePreviewWrapper); 58 59 // Initial preview - updatePreview(); 60 + updatePreviewWrapper(); 61 62 // Track editor focus 63 editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; }); 64 editorView.contentDOM.addEventListener('blur', () => { 65 isEditorFocused = false; 66 if (isMobileDevice()) { - exitMobileKeyboardMode(); 67 + exitMobileKeyboardMode(editorView, updatePreviewWrapper); 68 } 69 }); 70 72 73 // Keyboard shortcuts (only when editor not focused, since CodeMirror handles its own) 74 document.addEventListener('keydown', function(e) { - // Skip if editor is focused - CodeMirror handles these 75 + const {closeSearchPanel, openSearchPanel} = window.CodeMirror; 76 const editorHasFocus = editorView.hasFocus; 77 78 if ((e.metaKey || e.ctrlKey) && e.key === 'f') { 88 if ((e.metaKey || e.ctrlKey) && e.key === 'p') { 89 e.preventDefault(); 90 try { - preview.contentWindow.print(); 91 + document.getElementById('preview').contentWindow.print(); 92 } catch (err) { 93 window.print(); 94 } 95 } 96 }); 97 - // 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 }); -} 98 + // Initialize mobile keyboard handling 99 + initializeMobileKeyboard( 100 + () => isEditorFocused, 101 + () => exitMobileKeyboardMode(editorView, updatePreviewWrapper) 102 + ); 103 -// 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); 104 + // Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px) 105 + window.addEventListener('resize', function() { 106 + if (getIsFullscreen() && window.innerHeight <= 200) { 107 + toggleFullscreen(updatePreviewWrapper); 108 } - 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); 109 + }); 110 } 111 -// Exit fullscreen when viewport is too short (matches CSS @media max-height: 200px) -window.addEventListener('resize', function() { - if (isFullscreen && window.innerHeight <= 200) { - toggleFullscreen(); - } -}); - 112 // Initialize -initializeCodeMirror(); 113 +initializeEditor();
1 +import { insertAfterHead, insertBeforeHeadClose } from './html-utils.js'; 2 +import { generateViewerCode } from './viewer-generator.js'; 3 +import { saveToStorage } from './storage.js'; 4 +import { setCurrentSpread, updateSpreadIndicator } from './spread-navigation.js'; 5 + 6 +// File operations 7 +function downloadHtmlFile(content, filename = 'my-zine.html') { 8 + const blob = new Blob([content], { type: 'text/html' }); 9 + const url = URL.createObjectURL(blob); 10 + const a = document.createElement('a'); 11 + a.href = url; 12 + a.download = filename; 13 + a.click(); 14 + URL.revokeObjectURL(url); 15 +} 16 + 17 +export function saveFile(getEditorContent) { 18 + downloadHtmlFile(getEditorContent()); 19 +} 20 + 21 +export function saveFileWithViewer(getEditorContent) { 22 + const code = getEditorContent(); 23 + const { css, js } = generateViewerCode(); 24 + 25 + // Check if code has proper head tags 26 + const hasHead = /<head[^>]*>/i.test(code) && /<\/head>/i.test(code); 27 + 28 + let processedCode; 29 + if (hasHead) { 30 + // Inject viewer CSS at START of head (so user styles take precedence for page content) 31 + // Inject viewer JS at END of head (needs to run after DOM is ready) 32 + processedCode = insertAfterHead(code, `\n<style id="zine-viewer-css">${css}</style>`); 33 + processedCode = insertBeforeHeadClose(processedCode, `<script id="zine-viewer-js">${js}<\/script>\n`); 34 + } else { 35 + // No proper head found, wrap content 36 + 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>`; 37 + } 38 + 39 + downloadHtmlFile(processedCode); 40 +} 41 + 42 +export function loadFile(editorView, updatePreviewCallback) { 43 + const input = document.createElement('input'); 44 + input.type = 'file'; 45 + input.accept = '.html,.htm'; 46 + input.onchange = function(event) { 47 + const file = event.target.files[0]; 48 + if (!file) return; 49 + 50 + const reader = new FileReader(); 51 + reader.onload = function(e) { 52 + editorView.dispatch({ 53 + changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result } 54 + }); 55 + saveToStorage(e.target.result); 56 + setCurrentSpread(0); 57 + updateSpreadIndicator(); 58 + updatePreviewCallback(); 59 + }; 60 + reader.readAsText(file); 61 + }; 62 + input.click(); 63 +}
1 +// Insert content right after <head> tag in HTML string 2 +export function insertAfterHead(html, content) { 3 + const headMatch = html.match(/<head[^>]*>/i); 4 + if (headMatch) { 5 + const insertPos = html.indexOf(headMatch[0]) + headMatch[0].length; 6 + return html.slice(0, insertPos) + content + html.slice(insertPos); 7 + } 8 + return html; 9 +} 10 + 11 +// Insert content right before </head> tag in HTML string 12 +export function insertBeforeHeadClose(html, content) { 13 + const headEndMatch = html.match(/<\/head>/i); 14 + if (headEndMatch) { 15 + const insertPos = html.indexOf(headEndMatch[0]); 16 + return html.slice(0, insertPos) + content + html.slice(insertPos); 17 + } 18 + return html; 19 +} 20 + 21 +// Extract title and favicon from user's HTML 22 +export function extractTitleAndFavicon(htmlCode) { 23 + const parser = new DOMParser(); 24 + const doc = parser.parseFromString(htmlCode, 'text/html'); 25 + const titleElement = doc.querySelector('title'); 26 + const title = titleElement ? titleElement.textContent.trim() : null; 27 + const faviconSelectors = [ 28 + 'link[rel="icon"]', 29 + 'link[rel="shortcut icon"]', 30 + 'link[rel="apple-touch-icon"]' 31 + ]; 32 + let favicon = null; 33 + for (const selector of faviconSelectors) { 34 + const faviconElement = doc.querySelector(selector); 35 + if (faviconElement && faviconElement.getAttribute('href')) { 36 + favicon = faviconElement.getAttribute('href'); 37 + break; 38 + } 39 + } 40 + return { title, favicon }; 41 +} 42 + 43 +export function updateMainPageTitleAndFavicon(title, favicon) { 44 + if (title) { 45 + document.title = title; 46 + } else { 47 + document.title = 'zine.html'; 48 + } 49 + let faviconLink = document.querySelector('link[rel="icon"]'); 50 + if (!faviconLink) { 51 + faviconLink = document.createElement('link'); 52 + faviconLink.rel = 'icon'; 53 + document.head.appendChild(faviconLink); 54 + } 55 + if (favicon) { 56 + faviconLink.href = favicon; 57 + } else { 58 + 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>'; 59 + } 60 +} 61 + 62 +// Create an empty placeholder div for single-page spreads 63 +export function createEmptyPlaceholder(doc) { 64 + const empty = doc.createElement('div'); 65 + empty.className = 'zine-empty'; 66 + return empty; 67 +}
1 +let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; 2 + 3 +// Mobile keyboard detection 4 +export function isMobileDevice() { 5 + return window.matchMedia("(pointer: coarse), (pointer: none)").matches; 6 +} 7 + 8 +export function exitMobileKeyboardMode(editorView, updatePreviewCallback) { 9 + const editorPane = document.querySelector('.editor-pane'); 10 + if (!document.body.classList.contains('mobile-keyboard-open')) return; 11 + 12 + editorPane.style.opacity = '0'; 13 + document.body.classList.remove('mobile-keyboard-open'); 14 + 15 + requestAnimationFrame(() => requestAnimationFrame(() => { 16 + if (editorView) { 17 + const pos = editorView.state.selection.main.head; 18 + const lineBlock = editorView.lineBlockAt(pos); 19 + const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); 20 + editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); 21 + } 22 + editorPane.style.opacity = ''; 23 + updatePreviewCallback(); 24 + })); 25 +} 26 + 27 +function updateViewportVariables() { 28 + const vv = window.visualViewport; 29 + if (vv) { 30 + document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`); 31 + document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`); 32 + } 33 +} 34 + 35 +function handleViewportChange(isEditorFocused, exitCallback) { 36 + if (!isMobileDevice()) return; 37 + 38 + const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; 39 + const heightDifference = initialViewportHeight - currentHeight; 40 + const isKeyboardOpen = heightDifference > 150; 41 + 42 + updateViewportVariables(); 43 + 44 + if (isKeyboardOpen && isEditorFocused) { 45 + document.body.classList.add('mobile-keyboard-open'); 46 + } else if (!isKeyboardOpen) { 47 + exitCallback(); 48 + } 49 +} 50 + 51 +export function initializeMobileKeyboard(getIsEditorFocused, exitCallback) { 52 + const handleChange = () => handleViewportChange(getIsEditorFocused(), exitCallback); 53 + 54 + if (window.visualViewport) { 55 + window.visualViewport.addEventListener('resize', handleChange); 56 + window.visualViewport.addEventListener('scroll', updateViewportVariables); 57 + } else { 58 + window.addEventListener('resize', handleChange); 59 + } 60 +}
1 +import { insertAfterHead } from './html-utils.js'; 2 +import { extractTitleAndFavicon, updateMainPageTitleAndFavicon } from './html-utils.js'; 3 +import { generateSpreadCSS } from './zine-styles.js'; 4 +import { SPREADS } from './constants.js'; 5 +import { getCurrentSpread } from './spread-navigation.js'; 6 +import { isMobileDevice } from './mobile-keyboard.js'; 7 +import { setupSpreadLayout, scaleSpreadToFit } from './spread-layout.js'; 8 + 9 +export function updatePreview(editorView, isEditorFocused) { 10 + const preview = document.getElementById('preview'); 11 + 12 + if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) { 13 + return; 14 + } 15 + 16 + const code = editorView.state.doc.toString(); 17 + 18 + // Extract and update title and favicon from user's HTML 19 + const { title, favicon } = extractTitleAndFavicon(code); 20 + updateMainPageTitleAndFavicon(title, favicon); 21 + 22 + let scrollX = 0, scrollY = 0; 23 + try { 24 + if (preview.contentWindow?.scrollX !== undefined) { 25 + scrollX = preview.contentWindow.scrollX; 26 + scrollY = preview.contentWindow.scrollY; 27 + } 28 + } catch (e) {} 29 + 30 + // Inject spread CSS right after <head> so user styles come last and take precedence 31 + const currentSpread = getCurrentSpread(); 32 + const spreadCSS = generateSpreadCSS(currentSpread); 33 + const processedCode = insertAfterHead(code, `<style id="zine-editor-spread-css">${spreadCSS}</style>`); 34 + 35 + preview.srcdoc = processedCode || '<!DOCTYPE html><html><head></head><body></body></html>'; 36 + 37 + const onLoad = () => { 38 + try { 39 + const doc = preview.contentDocument; 40 + if (!doc) return; 41 + 42 + // Add CSS for overscroll and fullscreen button 43 + const style = doc.createElement('style'); 44 + 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; } }'; 45 + doc.head.appendChild(style); 46 + 47 + // Create fullscreen button 48 + const existingButton = doc.getElementById('fullscreenToggle'); 49 + if (existingButton) existingButton.remove(); 50 + 51 + const button = doc.createElement('button'); 52 + button.id = 'fullscreenToggle'; 53 + button.className = 'iframe-fullscreen-toggle'; 54 + button.title = 'Toggle fullscreen'; 55 + 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>'; 56 + button.addEventListener('click', function() { 57 + parent.postMessage('toggleFullscreen', '*'); 58 + }); 59 + 60 + if (doc.body) { 61 + doc.body.appendChild(button); 62 + 63 + // Create container for the spread and wrap visible pages 64 + const spread = SPREADS[currentSpread]; 65 + const container = doc.createElement('div'); 66 + container.className = 'zine-spread-container'; 67 + 68 + setupSpreadLayout(container, spread, doc); 69 + doc.body.appendChild(container); 70 + 71 + // Scale container to fit viewport 72 + const scaleToFit = () => scaleSpreadToFit(container, doc); 73 + scaleToFit(); 74 + doc.defaultView.addEventListener('resize', scaleToFit); 75 + } 76 + 77 + // Add keyboard listener for navigation and shortcuts 78 + doc.addEventListener('keydown', function(e) { 79 + if (e.key === 'ArrowLeft') { 80 + e.preventDefault(); 81 + parent.postMessage('prevSpread', '*'); 82 + } else if (e.key === 'ArrowRight') { 83 + e.preventDefault(); 84 + parent.postMessage('nextSpread', '*'); 85 + } else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { 86 + e.preventDefault(); 87 + window.print(); 88 + } else if ((e.metaKey || e.ctrlKey) && e.key === 's') { 89 + e.preventDefault(); 90 + parent.postMessage('saveFile', '*'); 91 + } else if ((e.metaKey || e.ctrlKey) && e.key === 'e') { 92 + e.preventDefault(); 93 + parent.postMessage('saveFileWithViewer', '*'); 94 + } 95 + }); 96 + 97 + setTimeout(() => { 98 + try { 99 + preview.contentWindow?.scrollTo(scrollX, scrollY); 100 + } catch (e) {} 101 + }, 10); 102 + } catch (e) {} 103 + 104 + preview.removeEventListener('load', onLoad); 105 + }; 106 + preview.addEventListener('load', onLoad); 107 +} 108 + 109 +// Setup preview pane interactions 110 +export function setupPreviewPane(updatePreviewCallback) { 111 + const previewPane = document.querySelector('.preview-pane'); 112 + const preview = document.getElementById('preview'); 113 + 114 + // Focus preview when clicking anywhere in the preview pane (for keyboard nav) 115 + previewPane.addEventListener('click', (e) => { 116 + // Don't steal focus if clicking nav buttons 117 + if (!e.target.closest('.spread-nav button')) { 118 + preview.focus(); 119 + } 120 + }); 121 +}
1 +import { SPREADS } from './constants.js'; 2 +import { createEmptyPlaceholder } from './html-utils.js'; 3 + 4 +// Common logic for displaying a spread (used by both editor preview and standalone viewer) 5 +export function setupSpreadLayout(container, spread, doc) { 6 + // Add empty placeholder before right page if no left page 7 + if (!spread.left && spread.right) { 8 + container.appendChild(createEmptyPlaceholder(doc)); 9 + } 10 + 11 + // Move visible pages into container 12 + const visiblePages = [spread.left, spread.right].filter(Boolean); 13 + visiblePages.forEach(id => { 14 + const page = doc.getElementById(id); 15 + if (page) container.appendChild(page); 16 + }); 17 + 18 + // Add empty placeholder after left page if no right page 19 + if (spread.left && !spread.right) { 20 + container.appendChild(createEmptyPlaceholder(doc)); 21 + } 22 +} 23 + 24 +// Scale spread container to fit viewport 25 +export function scaleSpreadToFit(container, doc, bottomPadding = 0) { 26 + const vw = doc.documentElement.clientWidth; 27 + const vh = doc.documentElement.clientHeight - bottomPadding; 28 + const spreadWidthPx = container.offsetWidth; 29 + const spreadHeightPx = container.offsetHeight; 30 + if (spreadWidthPx === 0 || spreadHeightPx === 0) return; 31 + const scaleX = (vw - 40) / spreadWidthPx; 32 + const scaleY = (vh - 40) / spreadHeightPx; 33 + const scale = Math.min(scaleX, scaleY); 34 + container.style.transform = `scale(${scale})`; 35 +}
1 +import { SPREADS } from './constants.js'; 2 + 3 +// State 4 +let currentSpread = 0; 5 +let isFullscreen = false; 6 + 7 +// Get current spread index 8 +export function getCurrentSpread() { 9 + return currentSpread; 10 +} 11 + 12 +// Set current spread index 13 +export function setCurrentSpread(index) { 14 + currentSpread = index; 15 +} 16 + 17 +// Get fullscreen state 18 +export function getIsFullscreen() { 19 + return isFullscreen; 20 +} 21 + 22 +// Set fullscreen state 23 +export function setIsFullscreen(value) { 24 + isFullscreen = value; 25 +} 26 + 27 +// Update spread indicator and button states 28 +export function updateSpreadIndicator() { 29 + const indicator = document.getElementById('spreadIndicator'); 30 + const prevBtn = document.getElementById('prevSpread'); 31 + const nextBtn = document.getElementById('nextSpread'); 32 + 33 + indicator.textContent = SPREADS[currentSpread].label; 34 + prevBtn.disabled = currentSpread === 0; 35 + nextBtn.disabled = currentSpread === SPREADS.length - 1; 36 +} 37 + 38 +// Navigate between spreads 39 +export function navigateSpread(delta, updatePreviewCallback) { 40 + const newSpread = currentSpread + delta; 41 + if (newSpread >= 0 && newSpread < SPREADS.length) { 42 + currentSpread = newSpread; 43 + updateSpreadIndicator(); 44 + updatePreviewCallback(); 45 + // Refocus preview so keyboard navigation continues to work 46 + document.getElementById('preview').focus(); 47 + } 48 +} 49 + 50 +// Fullscreen toggle 51 +export function toggleFullscreen(updatePreviewCallback) { 52 + isFullscreen = !isFullscreen; 53 + const editorPane = document.querySelector('.editor-pane'); 54 + const previewPane = document.querySelector('.preview-pane'); 55 + const preview = document.getElementById('preview'); 56 + 57 + if (isFullscreen) { 58 + previewPane.classList.add('fullscreen'); 59 + editorPane.classList.add('hidden'); 60 + preview.style.height = window.innerHeight + 'px'; 61 + } else { 62 + previewPane.classList.remove('fullscreen'); 63 + editorPane.classList.remove('hidden'); 64 + preview.style.height = ''; 65 + } 66 + 67 + try { 68 + const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); 69 + if (toggleButton) { 70 + toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; 71 + } 72 + } catch (e) {} 73 + 74 + // Trigger resize after layout updates so scaleToFit() recalculates 75 + requestAnimationFrame(() => { 76 + try { 77 + preview.contentWindow.dispatchEvent(new Event('resize')); 78 + } catch (e) {} 79 + }); 80 +} 81 + 82 +// Initialize spread navigation UI 83 +export function initializeSpreadNav(navigateCallback) { 84 + document.getElementById('prevSpread').addEventListener('click', () => navigateCallback(-1)); 85 + document.getElementById('nextSpread').addEventListener('click', () => navigateCallback(1)); 86 + updateSpreadIndicator(); 87 +}
1 +import { STORAGE_KEY, ZINE_BOILERPLATE } from './constants.js'; 2 + 3 +export function saveToStorage(content) { 4 + try { 5 + localStorage.setItem(STORAGE_KEY, content); 6 + } catch (e) { 7 + console.warn('Could not save to localStorage:', e); 8 + } 9 +} 10 + 11 +export async function loadContent() { 12 + let content = ZINE_BOILERPLATE; 13 + try { 14 + const saved = localStorage.getItem(STORAGE_KEY); 15 + if (saved && saved.trim()) { 16 + content = saved; 17 + } 18 + } catch (e) { 19 + console.warn('Could not load from localStorage:', e); 20 + } 21 + return { content, isBoilerplate: content === ZINE_BOILERPLATE }; 22 +} 23 + 24 +export function loadEditorSettings() { 25 + try { 26 + const showLineNumbers = localStorage.getItem('zine-editor-line-numbers') === 'true'; 27 + const enableLineWrapping = localStorage.getItem('zine-editor-line-wrapping') !== 'false'; 28 + return { showLineNumbers, enableLineWrapping }; 29 + } catch (e) { 30 + return { showLineNumbers: false, enableLineWrapping: true }; 31 + } 32 +} 33 + 34 +export function saveEditorSetting(key, value) { 35 + try { 36 + localStorage.setItem(key, value.toString()); 37 + } catch (e) {} 38 +}
1 +import { PAGE_IDS, SPREADS, NAV_HEIGHT, NAV_BUTTON_CSS, NAV_BUTTON_STYLES } from './constants.js'; 2 +import { ZINE_PAGE_CSS, ZINE_PRINT_CSS } from './zine-styles.js'; 3 + 4 +// Generate standalone viewer CSS and JS (matches preview pane rendering) 5 +export function generateViewerCode() { 6 + const css = ` 7 + * { margin: 0; padding: 0; box-sizing: border-box; } 8 + 9 + @media screen { 10 + html, body { 11 + height: 100% !important; 12 + overflow: hidden !important; 13 + } 14 + body { 15 + background: linear-gradient(to top, #2a2a2a, #3a3a3a) !important; 16 + display: flex !important; 17 + justify-content: center !important; 18 + align-items: center !important; 19 + padding-bottom: ${NAV_HEIGHT}px !important; 20 + } 21 + body > *:not(.zine-spread-container):not(.zine-nav) { 22 + display: none !important; 23 + } 24 + ${ZINE_PAGE_CSS} 25 + .zine-spread-container { 26 + display: flex !important; 27 + position: relative; 28 + } 29 + .zine-nav { 30 + position: fixed; 31 + bottom: 0; 32 + left: 0; 33 + right: 0; 34 + height: ${NAV_HEIGHT}px; 35 + ${NAV_BUTTON_CSS} 36 + background: transparent; 37 + } 38 + .zine-nav button { 39 + ${NAV_BUTTON_STYLES} 40 + } 41 + .zine-nav button:hover:not(:disabled) { background: rgba(255,255,255,0.25); } 42 + .zine-nav button:disabled { opacity: 0.3; cursor: default; } 43 + .zine-nav span { color: white; font-size: 14px; min-width: 100px; text-align: center; font-family: monospace; } 44 + } 45 + 46 + @media print { 47 + ${ZINE_PRINT_CSS} 48 + } 49 + 50 + /* Hide content when viewport is very short */ 51 + @media screen and (max-height: 200px) { 52 + .zine-spread-container, .zine-nav { 53 + display: none !important; 54 + } 55 + } 56 + `; 57 + 58 + // Serialize SPREADS array for the viewer 59 + const spreadsJson = JSON.stringify(SPREADS); 60 + const pageIdsJson = JSON.stringify(PAGE_IDS); 61 + 62 + const js = ` 63 + const SPREADS = ${spreadsJson}; 64 + const PAGE_IDS = ${pageIdsJson}; 65 + const NAV_HEIGHT = ${NAV_HEIGHT}; 66 + let currentSpread = 0; 67 + let container; 68 + 69 + function scaleToFit() { 70 + if (!container) return; 71 + const vw = document.documentElement.clientWidth; 72 + const vh = document.documentElement.clientHeight - NAV_HEIGHT; 73 + const spreadWidthPx = container.offsetWidth; 74 + const spreadHeightPx = container.offsetHeight; 75 + if (spreadWidthPx === 0 || spreadHeightPx === 0) return; 76 + const scaleX = (vw - 40) / spreadWidthPx; 77 + const scaleY = (vh - 40) / spreadHeightPx; 78 + const scale = Math.min(scaleX, scaleY); 79 + container.style.transform = 'scale(' + scale + ')'; 80 + } 81 + 82 + function showSpread(index) { 83 + const spread = SPREADS[index]; 84 + const visible = [spread.left, spread.right].filter(Boolean); 85 + 86 + // Move existing pages back to body before clearing container 87 + PAGE_IDS.forEach(id => { 88 + const page = document.getElementById(id); 89 + if (page) { 90 + page.style.display = 'none'; 91 + document.body.appendChild(page); 92 + } 93 + }); 94 + 95 + container.innerHTML = ''; 96 + 97 + if (!spread.left && spread.right) { 98 + const empty = document.createElement('div'); 99 + empty.className = 'zine-empty'; 100 + container.appendChild(empty); 101 + } 102 + 103 + visible.forEach(id => { 104 + const el = document.getElementById(id); 105 + if (el) { 106 + el.style.display = 'block'; 107 + container.appendChild(el); 108 + } 109 + }); 110 + 111 + if (spread.left && !spread.right) { 112 + const empty = document.createElement('div'); 113 + empty.className = 'zine-empty'; 114 + container.appendChild(empty); 115 + } 116 + 117 + document.getElementById('zine-indicator').textContent = spread.label; 118 + document.getElementById('zine-prev').disabled = index === 0; 119 + document.getElementById('zine-next').disabled = index === SPREADS.length - 1; 120 + scaleToFit(); 121 + } 122 + 123 + function navigate(delta) { 124 + const newIndex = currentSpread + delta; 125 + if (newIndex >= 0 && newIndex < SPREADS.length) { 126 + currentSpread = newIndex; 127 + showSpread(currentSpread); 128 + } 129 + } 130 + 131 + document.addEventListener('DOMContentLoaded', () => { 132 + container = document.createElement('div'); 133 + container.className = 'zine-spread-container'; 134 + document.body.appendChild(container); 135 + 136 + const nav = document.createElement('div'); 137 + nav.className = 'zine-nav'; 138 + 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>'; 139 + document.body.appendChild(nav); 140 + 141 + document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); 142 + document.getElementById('zine-next').addEventListener('click', () => navigate(1)); 143 + 144 + document.addEventListener('keydown', e => { 145 + if (e.key === 'ArrowLeft') navigate(-1); 146 + else if (e.key === 'ArrowRight') navigate(1); 147 + else if ((e.metaKey || e.ctrlKey) && e.key === 'p') { 148 + e.preventDefault(); 149 + window.print(); 150 + } 151 + else if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'e')) { 152 + e.preventDefault(); 153 + // Re-download this viewer file (clean version without dynamic elements) 154 + // Move pages back to body and remove dynamic elements temporarily 155 + PAGE_IDS.forEach(id => { 156 + const page = document.getElementById(id); 157 + if (page) { 158 + page.style.display = ''; 159 + document.body.appendChild(page); 160 + } 161 + }); 162 + const tempContainer = document.querySelector('.zine-spread-container'); 163 + const tempNav = document.querySelector('.zine-nav'); 164 + if (tempContainer) tempContainer.remove(); 165 + if (tempNav) tempNav.remove(); 166 + 167 + const html = '<!DOCTYPE html>' + document.documentElement.outerHTML; 168 + 169 + // Restore dynamic elements 170 + const newContainer = document.createElement('div'); 171 + newContainer.className = 'zine-spread-container'; 172 + document.body.appendChild(newContainer); 173 + container = newContainer; 174 + 175 + const newNav = document.createElement('div'); 176 + newNav.className = 'zine-nav'; 177 + 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>'; 178 + document.body.appendChild(newNav); 179 + document.getElementById('zine-prev').addEventListener('click', () => navigate(-1)); 180 + document.getElementById('zine-next').addEventListener('click', () => navigate(1)); 181 + 182 + showSpread(currentSpread); 183 + 184 + const blob = new Blob([html], { type: 'text/html' }); 185 + const url = URL.createObjectURL(blob); 186 + const a = document.createElement('a'); 187 + a.href = url; 188 + a.download = 'my-zine.html'; 189 + a.click(); 190 + URL.revokeObjectURL(url); 191 + } 192 + }); 193 + 194 + window.addEventListener('resize', scaleToFit); 195 + 196 + showSpread(0); 197 + }); 198 + `; 199 + 200 + return { css, js }; 201 +}
1 +import { PAGE_WIDTH_IN, PAGE_HEIGHT_IN, PAGE_PADDING_IN, ALL_PAGES_SELECTOR, RIGHT_PAGES_SELECTOR, LEFT_PAGES_SELECTOR, SPREADS } from './constants.js'; 2 + 3 +// Shared print CSS for zine layout (used by both editor preview and exported viewer) 4 +export const ZINE_PRINT_CSS = ` 5 + @page { size: 8.5in 11in portrait; margin: 0; } 6 + html, body { width: 8.5in; height: 11in; } 7 + body { 8 + display: grid !important; 9 + grid-template-columns: repeat(4, ${PAGE_WIDTH_IN}in); 10 + grid-template-rows: repeat(2, ${PAGE_HEIGHT_IN}in); 11 + transform: rotate(90deg); 12 + transform-origin: center center; 13 + position: absolute; 14 + top: calc(50% - ${PAGE_HEIGHT_IN}in); 15 + left: calc(50% - 5.5in); 16 + width: 11in; 17 + height: 8.5in; 18 + } 19 + ${ALL_PAGES_SELECTOR} { 20 + display: block !important; 21 + width: ${PAGE_WIDTH_IN}in !important; 22 + height: ${PAGE_HEIGHT_IN}in !important; 23 + padding: ${PAGE_PADDING_IN}in; 24 + background: white; 25 + overflow: hidden; 26 + overflow-wrap: break-word; 27 + aspect-ratio: auto !important; 28 + box-shadow: none !important; 29 + transform: none; 30 + } 31 + #front-cover { grid-row: 2; grid-column: 2; } 32 + #page1 { grid-row: 2; grid-column: 3; } 33 + #page2 { grid-row: 2; grid-column: 4; } 34 + #page3 { grid-row: 1; grid-column: 4; transform: rotate(180deg); } 35 + #page4 { grid-row: 1; grid-column: 3; transform: rotate(180deg); } 36 + #page5 { grid-row: 1; grid-column: 2; transform: rotate(180deg); } 37 + #page6 { grid-row: 1; grid-column: 1; transform: rotate(180deg); } 38 + #back-cover { grid-row: 2; grid-column: 1; } 39 + .zine-nav, .zine-empty, .spread-nav, .iframe-fullscreen-toggle, .zine-spread-container { display: contents !important; } 40 + .zine-spread-container { transform: none !important; } 41 + a { color: black; } 42 +`; 43 + 44 +// Shared CSS for zine pages (used by both editor preview and exported viewer) 45 +export const ZINE_PAGE_CSS = ` 46 + ${ALL_PAGES_SELECTOR} { 47 + display: none; 48 + width: ${PAGE_WIDTH_IN}in; 49 + height: ${PAGE_HEIGHT_IN}in; 50 + flex-shrink: 0; 51 + padding: ${PAGE_PADDING_IN}in; 52 + overflow: hidden; 53 + overflow-wrap: break-word; 54 + } 55 + .page { 56 + background: white; 57 + } 58 + .zine-spread-container { 59 + display: flex; 60 + transform-origin: center center; 61 + } 62 + ${RIGHT_PAGES_SELECTOR} { 63 + 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); 64 + } 65 + ${LEFT_PAGES_SELECTOR} { 66 + 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); 67 + } 68 + .zine-empty { 69 + width: ${PAGE_WIDTH_IN}in; 70 + height: ${PAGE_HEIGHT_IN}in; 71 + flex-shrink: 0; 72 + border: 1px dashed #666; 73 + } 74 + .zine-empty:first-child { 75 + border-right: none; 76 + } 77 + .zine-empty:last-child { 78 + border-left: none; 79 + } 80 +`; 81 + 82 +// Generate CSS to show only the current spread 83 +export function generateSpreadCSS(spreadIndex) { 84 + const spread = SPREADS[spreadIndex]; 85 + const visiblePages = [spread.left, spread.right].filter(Boolean); 86 + 87 + return ` 88 + /* Base styles for all views */ 89 + * { margin: 0; padding: 0; box-sizing: border-box; } 90 + 91 + /* Screen view: show spread at actual size, then scale to fit */ 92 + @media screen { 93 + html, body { 94 + height: 100% !important; 95 + overflow: hidden !important; 96 + } 97 + 98 + body { 99 + display: flex !important; 100 + justify-content: center !important; 101 + align-items: center !important; 102 + } 103 + 104 + body > *:not(.zine-spread-container):not(.iframe-fullscreen-toggle) { 105 + display: none !important; 106 + } 107 + 108 + ${ZINE_PAGE_CSS} 109 + 110 + .zine-spread-container { 111 + display: flex !important; 112 + position: relative; 113 + } 114 + 115 + ${ALL_PAGES_SELECTOR} { 116 + display: none !important; 117 + width: ${PAGE_WIDTH_IN}in !important; 118 + height: ${PAGE_HEIGHT_IN}in !important; 119 + } 120 + 121 + /* Show visible pages */ 122 + ${visiblePages.map(id => `#${id}`).join(', ')} { 123 + display: block !important; 124 + } 125 + 126 + .zine-empty { 127 + display: block !important; 128 + width: ${PAGE_WIDTH_IN}in !important; 129 + height: ${PAGE_HEIGHT_IN}in !important; 130 + } 131 + } 132 + 133 + /* Print view: 8-page grid for mini-zine folding */ 134 + @media print { 135 + ${ZINE_PRINT_CSS} 136 + } 137 + `; 138 +}