cleanup

Hunter
Monday, February 2nd, 2026 at 3:15 PM

resources/editor.js+126-135

resources/editor.js
  5  const previewPane = document.querySelector('.preview-pane');
  6  const storageKey = 'zine-editor-content';
  7  
  8 +// Insert content right after <head> tag in HTML string
  9 +function insertAfterHead(html, content) {
 10 +	const headMatch = html.match(/<head[^>]*>/i);
 11 +	if (headMatch) {
 12 +		const insertPos = html.indexOf(headMatch[0]) + headMatch[0].length;
 13 +		return html.slice(0, insertPos) + content + html.slice(insertPos);
 14 +	}
 15 +	return html;
 16 +}
 17 +
 18 +// Insert content right before </head> tag in HTML string
 19 +function insertBeforeHeadClose(html, content) {
 20 +	const headEndMatch = html.match(/<\/head>/i);
 21 +	if (headEndMatch) {
 22 +		const insertPos = html.indexOf(headEndMatch[0]);
 23 +		return html.slice(0, insertPos) + content + html.slice(insertPos);
 24 +	}
 25 +	return html;
 26 +}
 27 +
 28  // Extract title and favicon from user's HTML
 29  function extractTitleAndFavicon(htmlCode) {
 30  	const parser = new DOMParser();

108  	return startPos + ZINE_BOILERPLATE.indexOf('<div class="page" id="front-cover">') + 39;
109  }
110  
111 +// Zine page dimensions in inches
112 +const PAGE_WIDTH_IN = 2.75;
113 +const PAGE_HEIGHT_IN = 4.25;
114 +const PAGE_PADDING_IN = 0.2;
115 +
116 +// All page IDs for selectors
117 +const PAGE_IDS = ['front-cover', 'page1', 'page2', 'page3', 'page4', 'page5', 'page6', 'back-cover'];
118 +const ALL_PAGES_SELECTOR = PAGE_IDS.map(id => `#${id}`).join(', ');
119 +const RIGHT_PAGES_SELECTOR = '#front-cover, #page2, #page4, #page6';
120 +const LEFT_PAGES_SELECTOR = '#back-cover, #page1, #page3, #page5';
121 +
122 +// Create an empty placeholder div for single-page spreads
123 +function createEmptyPlaceholder(doc) {
124 +	const empty = doc.createElement('div');
125 +	empty.className = 'zine-empty';
126 +	return empty;
127 +}
128 +
129 +// Spread definitions
130 +const SPREADS = [
131 +	{ left: null, right: 'front-cover', label: 'Front Cover' },
132 +	{ left: 'page1', right: 'page2', label: 'Pages 1-2' },
133 +	{ left: 'page3', right: 'page4', label: 'Pages 3-4' },
134 +	{ left: 'page5', right: 'page6', label: 'Pages 5-6' },
135 +	{ left: 'back-cover', right: null, label: 'Back Cover' }
136 +];
137 +
138 +let currentSpread = 0;
139 +let isFullscreen = false;
140 +let showLineNumbers = false;
141 +let enableLineWrapping = false;
142 +let lineNumbersCompartment;
143 +let lineWrappingCompartment;
144 +let isEditorFocused = false;
145 +let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
146 +
147  // Shared print CSS for zine layout (used by both editor preview and exported viewer)
148  const ZINE_PRINT_CSS = `
149  	@page { size: 8.5in 11in portrait; margin: 0; }
150  	html, body { width: 8.5in; height: 11in; }
151  	body {
152  		display: grid !important;
    -		grid-template-columns: repeat(4, 2.75in);
    -		grid-template-rows: repeat(2, 4.25in);
153 +		grid-template-columns: repeat(4, ${PAGE_WIDTH_IN}in);
154 +		grid-template-rows: repeat(2, ${PAGE_HEIGHT_IN}in);
155  		transform: rotate(90deg);
156  		transform-origin: center center;
157  		position: absolute;
    -		top: calc(50% - 4.25in);
158 +		top: calc(50% - ${PAGE_HEIGHT_IN}in);
159  		left: calc(50% - 5.5in);
160  		width: 11in;
161  		height: 8.5in;
162  	}
    -	#front-cover, #page1, #page2, #page3, #page4, #page5, #page6, #back-cover {
163 +	${ALL_PAGES_SELECTOR} {
164  		display: block !important;
    -		width: 2.75in !important;
    -		height: 4.25in !important;
    -		padding: 0.2in;
165 +		width: ${PAGE_WIDTH_IN}in !important;
166 +		height: ${PAGE_HEIGHT_IN}in !important;
167 +		padding: ${PAGE_PADDING_IN}in;
168  		background: white;
169  		overflow: hidden;
170  		overflow-wrap: break-word;

185  	a { color: black; }
186  `;
187  
    -// Spread definitions
    -const SPREADS = [
    -	{ left: null, right: 'front-cover', label: 'Front Cover' },
    -	{ left: 'page1', right: 'page2', label: 'Pages 1-2' },
    -	{ left: 'page3', right: 'page4', label: 'Pages 3-4' },
    -	{ left: 'page5', right: 'page6', label: 'Pages 5-6' },
    -	{ left: 'back-cover', right: null, label: 'Back Cover' }
    -];
    -
    -let currentSpread = 0;
    -let isFullscreen = false;
    -let showLineNumbers = false;
    -let enableLineWrapping = false;
    -let lineNumbersCompartment;
    -let lineWrappingCompartment;
    -let isEditorFocused = false;
    -let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
    -
    -// Zine page dimensions in inches
    -const PAGE_WIDTH_IN = 2.75;
    -const PAGE_HEIGHT_IN = 4.25;
    -
188  // Shared CSS for zine pages (used by both editor preview and exported viewer)
189  const ZINE_PAGE_CSS = `
    -	#front-cover, #page1, #page2, #page3, #page4, #page5, #page6, #back-cover {
190 +	${ALL_PAGES_SELECTOR} {
191  		display: none;
192  		width: ${PAGE_WIDTH_IN}in;
193  		height: ${PAGE_HEIGHT_IN}in;
194  		flex-shrink: 0;
    -		padding: 0.2in;
195 +		padding: ${PAGE_PADDING_IN}in;
196  		overflow: hidden;
197  		overflow-wrap: break-word;
198  	}

203  		display: flex;
204  		transform-origin: center center;
205  	}
    -	#front-cover, #page2, #page4, #page6 {
206 +	${RIGHT_PAGES_SELECTOR} {
207  		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);
208  	}
    -	#back-cover, #page1, #page3, #page5 {
209 +	${LEFT_PAGES_SELECTOR} {
210  		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);
211  	}
212  	.zine-empty {

256  				position: relative;
257  			}
258  
    -			#front-cover, #page1, #page2, #page3, #page4, #page5, #page6, #back-cover {
259 +			${ALL_PAGES_SELECTOR} {
260  				display: none !important;
261  				width: ${PAGE_WIDTH_IN}in !important;
262  				height: ${PAGE_HEIGHT_IN}in !important;

362  
363  	// Inject spread CSS right after <head> so user styles come last and take precedence
364  	const spreadCSS = generateSpreadCSS(currentSpread);
    -	let processedCode = code;
    -	const headStartMatch = processedCode.match(/<head[^>]*>/i);
    -	if (headStartMatch) {
    -		const insertPos = processedCode.indexOf(headStartMatch[0]) + headStartMatch[0].length;
    -		processedCode = processedCode.slice(0, insertPos) +
    -			`<style id="zine-editor-spread-css">${spreadCSS}</style>` +
    -			processedCode.slice(insertPos);
    -	}
365 +	const processedCode = insertAfterHead(code, `<style id="zine-editor-spread-css">${spreadCSS}</style>`);
366  
367  	preview.srcdoc = processedCode || '<!DOCTYPE html><html><head></head><body></body></html>';
368  

399  
400  				// Add empty placeholder before right page if no left page
401  				if (!spread.left && spread.right) {
    -					const empty = doc.createElement('div');
    -					empty.className = 'zine-empty';
    -					container.appendChild(empty);
402 +					container.appendChild(createEmptyPlaceholder(doc));
403  				}
404  
405  				// Move visible pages into container

411  
412  				// Add empty placeholder after left page if no right page
413  				if (spread.left && !spread.right) {
    -					const empty = doc.createElement('div');
    -					empty.className = 'zine-empty';
    -					container.appendChild(empty);
414 +					container.appendChild(createEmptyPlaceholder(doc));
415  				}
416  
417  				doc.body.appendChild(container);

549  	});
550  }
551  
552 +// Shared navigation button CSS (used by both editor spread-nav and exported viewer zine-nav)
553 +const NAV_BUTTON_CSS = `
554 +	display: flex;
555 +	align-items: center;
556 +	justify-content: center;
557 +	gap: 16px;
558 +	padding: 12px 20px;
559 +`;
560 +
561 +const NAV_BUTTON_STYLES = `
562 +	background: rgba(255,255,255,0.15);
563 +	color: white;
564 +	border: none;
565 +	border-radius: 50%;
566 +	width: 36px;
567 +	height: 36px;
568 +	cursor: pointer;
569 +	font-size: 18px;
570 +	display: flex;
571 +	align-items: center;
572 +	justify-content: center;
573 +`;
574 +
575  // Generate standalone viewer CSS and JS (matches preview pane rendering)
576  function generateViewerCode() {
577  	const css = `

591  			body > *:not(.zine-spread-container):not(.zine-nav) {
592  				display: none !important;
593  			}
    -			#front-cover, #page1, #page2, #page3, #page4, #page5, #page6, #back-cover {
    -				display: none;
    -				width: ${PAGE_WIDTH_IN}in !important;
    -				height: ${PAGE_HEIGHT_IN}in !important;
    -				flex-shrink: 0;
    -				padding: 0.2in;
    -				overflow: hidden;
    -				overflow-wrap: break-word;
    -			}
    -			.page {
    -				background: white;
    -			}
594 +			${ZINE_PAGE_CSS}
595  			.zine-spread-container {
596  				display: flex !important;
    -				transform-origin: center center;
597  				position: relative;
598  			}
    -			#front-cover, #page2, #page4, #page6 {
    -				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);
    -			}
    -			#back-cover, #page1, #page3, #page5 {
    -				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;
    -			}
599  			.zine-nav {
600  				position: fixed;
601  				bottom: 0;
602  				left: 0;
603  				right: 0;
    -				display: flex;
    -				align-items: center;
    -				justify-content: center;
    -				gap: 16px;
    -				padding: 12px 20px;
604 +				${NAV_BUTTON_CSS}
605  				background: transparent;
606  			}
607  			.zine-nav button {
    -				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;
608 +				${NAV_BUTTON_STYLES}
609  			}
610  			.zine-nav button:hover:not(:disabled) { background: rgba(255,255,255,0.25); }
611  			.zine-nav button:disabled { opacity: 0.3; cursor: default; }

617  		}
618  	`;
619  
620 +	// Serialize SPREADS array for the viewer
621 +	const spreadsJson = JSON.stringify(SPREADS);
622 +	const pageIdsJson = JSON.stringify(PAGE_IDS);
623 +
624  	const js = `
    -		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' }
    -		];
625 +		const SPREADS = ${spreadsJson};
626 +		const PAGE_IDS = ${pageIdsJson};
627  		let currentSpread = 0;
628  		let container;
629  

645  			const visible = [spread.left, spread.right].filter(Boolean);
646  
647  			// Move existing pages back to body before clearing container
    -			const existingPages = container.querySelectorAll('#front-cover, #page1, #page2, #page3, #page4, #page5, #page6, #back-cover');
    -			existingPages.forEach(page => {
    -				page.style.display = 'none';
    -				document.body.appendChild(page);
648 +			PAGE_IDS.forEach(id => {
649 +				const page = document.getElementById(id);
650 +				if (page) {
651 +					page.style.display = 'none';
652 +					document.body.appendChild(page);
653 +				}
654  			});
655  
656  			container.innerHTML = '';

721  }
722  
723  // File operations
    -window.saveFile = function() {
    -	const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' });
724 +function downloadHtmlFile(content, filename = 'my-zine.html') {
725 +	const blob = new Blob([content], { type: 'text/html' });
726  	const url = URL.createObjectURL(blob);
727  	const a = document.createElement('a');
728  	a.href = url;
    -	a.download = 'my-zine.html';
729 +	a.download = filename;
730  	a.click();
731  	URL.revokeObjectURL(url);
732 +}
733 +
734 +window.saveFile = function() {
735 +	downloadHtmlFile(editorView.state.doc.toString());
736  };
737  
738  window.saveFileWithViewer = function() {
739  	const code = editorView.state.doc.toString();
740  	const { css, js } = generateViewerCode();
741  
    -	// 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)
    -	let processedCode = code;
    -	const headStartMatch = processedCode.match(/<head[^>]*>/i);
    -	const headEndMatch = processedCode.match(/<\/head>/i);
    -
    -	if (headStartMatch && headEndMatch) {
    -		// Insert CSS right after <head>
    -		const cssInsertPos = processedCode.indexOf(headStartMatch[0]) + headStartMatch[0].length;
    -		processedCode = processedCode.slice(0, cssInsertPos) +
    -			`\n<style id="zine-viewer-css">${css}</style>` +
    -			processedCode.slice(cssInsertPos);
    -
    -		// Insert JS before </head> (recalculate position after CSS insertion)
    -		const newHeadEndMatch = processedCode.match(/<\/head>/i);
    -		const jsInsertPos = processedCode.indexOf(newHeadEndMatch[0]);
    -		processedCode = processedCode.slice(0, jsInsertPos) +
    -			`<script id="zine-viewer-js">${js}<\/script>\n` +
    -			processedCode.slice(jsInsertPos);
742 +	// Check if code has proper head tags
743 +	const hasHead = /<head[^>]*>/i.test(code) && /<\/head>/i.test(code);
744 +
745 +	let processedCode;
746 +	if (hasHead) {
747 +		// Inject viewer CSS at START of head (so user styles take precedence for page content)
748 +		// Inject viewer JS at END of head (needs to run after DOM is ready)
749 +		processedCode = insertAfterHead(code, `\n<style id="zine-viewer-css">${css}</style>`);
750 +		processedCode = insertBeforeHeadClose(processedCode, `<script id="zine-viewer-js">${js}<\/script>\n`);
751  	} else {
752  		// No proper head found, wrap content
753  		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>`;
754  	}
755  
    -	const blob = new Blob([processedCode], { 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);
756 +	downloadHtmlFile(processedCode);
757  };
758  
759  window.loadFile = function() {