refactor; break up editor.js

Hunter
Monday, February 2nd, 2026 at 5:24 PM

resources/codemirror-config.jsnew
resources/constants.jsnew
resources/editor.js+42-1001
resources/file-operations.jsnew
resources/html-utils.jsnew
resources/mobile-keyboard.jsnew
resources/preview-manager.jsnew
resources/spread-layout.jsnew
resources/spread-navigation.jsnew
resources/storage.jsnew
resources/viewer-generator.jsnew
resources/zine-styles.jsnew

resources/codemirror-config.js
  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 +}
resources/constants.js
 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';
resources/editor.js
    -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();
resources/file-operations.js
 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 +}
resources/html-utils.js
 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 +}
resources/mobile-keyboard.js
 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 +}
resources/preview-manager.js
  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 +}
resources/spread-layout.js
 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 +}
resources/spread-navigation.js
 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 +}
resources/storage.js
 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 +}
resources/viewer-generator.js
  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 +}
resources/zine-styles.js
  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 +}