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