commit 1f20ff91d5870b2c2c8a99298999088809eecb10
parent 6f13a01208bd2b1a3d545cbf735290445ecca349
Author: Hunter
Date:   Mon, 22 Dec 2025 21:43:47 -0500

pull js and css out of index.html

Diffstat:
Mindex.html | 748+------------------------------------------------------------------------------
Amain.js | 529+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Astyles.css | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 739 insertions(+), 743 deletions(-)

diff --git a/index.html b/index.html @@ -6,6 +6,7 @@ <title>Web Workshop</title> <link rel="icon" href="resources/rollerskate.png"> <link rel="manifest" href="manifest.json"> + <link rel="stylesheet" href="styles.css"> <script type="module"> import {EditorView, keymap, placeholder, lineNumbers, Decoration} from "https://esm.sh/@codemirror/view@6" import {EditorState, Compartment} from "https://esm.sh/@codemirror/state@6" @@ -15,757 +16,19 @@ import {githubDark} from "https://esm.sh/@fsegurai/codemirror-theme-github-dark" import {indentUnit} from "https://esm.sh/@codemirror/language@6" import {search, searchKeymap, closeSearchPanel, openSearchPanel} from "https://esm.sh/@codemirror/search@6" - + window.CodeMirror = {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration}; </script> - <style> - :root { - --text-color: #f8f8f2; - --editor-bg: #16181b; - scrollbar-color: grey var(--editor-bg); - } - - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - html, body { - overscroll-behavior: none; - background-color: var(--editor-bg); - } - - html { - height: 100%; - } - - body { - font-family: monospace; - background: var(--bg-color); - color: var(--text-color); - height: 100%; - display: flex; - overflow: hidden; - } - - .editor-pane { - width: 50%; - height: 100%; - position: relative; - transition: opacity 0.1s ease-out; /* Added transition for smoother opacity hack */ - } - - .preview-pane { - width: 50%; - height: 100%; - position: relative; - } - - .preview-pane.fullscreen { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - z-index: 9999; - background: white; - } - - /* Hide editor when preview is fullscreen */ - .editor-pane.hidden { - display: none; - } - - #editor { - width: 100%; - height: 100%; - font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; - font-size: 21px; - } - - /* CodeMirror styling */ - .cm-editor { - width: 100%; - height: 100%; - background: var(--editor-bg); - color: var(--text-color); - } - - .cm-placeholder { - color: #666; - opacity: 0.6; - } - - .cm-content { - padding: 15px; - font-size: 21px; - line-height: 1.6; - } - - .cm-gutters { - font-size: 21px; - line-height: 1.6; - user-select: none; - } - - .cm-lineNumbers .cm-gutterElement { - line-height: 1.6; - font-size: 21px; - } - - .ͼ1 .cm-lineNumbers .cm-gutterElement { - padding-right: 0; - padding-left: 8px; - } - - .cm-focused { - outline: none; - } - - /* Force tab size to 2 spaces - override theme defaults */ - .cm-editor .cm-content, - .cm-editor .cm-line, - .cm-editor { - tab-size: 2 !important; - -moz-tab-size: 2 !important; - } - - /* Disable overscroll on all CodeMirror elements */ - .cm-editor, .cm-content, .cm-scroller, .cm-scrollElement { - overscroll-behavior: none !important; - } - - /* Simplify search panel - hide all buttons, labels, and replace input */ - .cm-search button { - display: none !important; - } - - .cm-search { - background-color: #16181b; - } - - .cm-search label { - display: none !important; - } - - .cm-search input[name="replace"] { - display: none !important; - } - - /* Style search input to match editor text size */ - .cm-search input[name="search"] { - font-size: 21px !important; - width: 100% !important; - border-radius: 50px !important; - padding-left: 0.8em !important; - padding-right: 0.8em !important; - padding-bottom: 7px !important; - } - - .cm-panels-bottom { - border-radius: 0px !important; - } - - .cm-panels-bottom { - border-top: none !important; - - } - - #preview { - width: 100%; - height: 100%; - border: none; - background: white; - overflow: auto; - } - - /* Vertical layout for narrow screens (height > width) */ - @media (max-aspect-ratio: 1/1) { - body { - flex-direction: column; - } - - .editor-pane { - width: 100%; - height: 50%; - order: 2; - } - - .preview-pane { - width: 100%; - height: 50%; - order: 1; - padding-top: env(safe-area-inset-top); - } - } - - /* Mobile: fullscreen editor when keyboard is open */ - @media (pointer: coarse), (pointer: none) { - body.mobile-keyboard-open .preview-pane { - display: none; - } - - body.mobile-keyboard-open .editor-pane { - position: fixed; - top: calc(var(--visual-viewport-offset-top, 0px)); - left: 0; - width: 100%; - height: var(--visual-viewport-height, 100%); - max-height: var(--visual-viewport-height, 100%); - order: 1; - overscroll-behavior: none; - overflow: hidden; - } - - body.mobile-keyboard-open .editor-pane #editor, - body.mobile-keyboard-open .editor-pane .cm-editor { - height: 100% !important; - max-height: 100% !important; - } - } - - </style> </head> <body> <div class="editor-pane"> <div id="editor"></div> </div> - + <div class="preview-pane"> <iframe id="preview"></iframe> </div> - <script type="module"> - let updateTimer; - let editorView; - const preview = document.getElementById('preview'); - - // Stock images loaded from manifest - can be referenced by bare filename - let stockImages = new Map(); // lowercase -> original filename - - // Load stock image list from manifest - const stockImagesReady = fetch('resource-manifest.json') - .then(r => r.json()) - .then(manifest => { - manifest.images.forEach(path => { - const filename = path.split('/').pop(); - stockImages.set(filename.toLowerCase(), filename); - }); - }) - .catch(() => console.log('Could not load resource manifest')); - - // Rewrite bare image filenames to use images/ prefix (case-insensitive, uses correct case) - function rewriteBareImageSrcs(html) { - return html.replace(/(<img\s[^>]*\bsrc\s*=\s*["'])([^"'/:]+\.(gif|png|jpg|jpeg|svg|webp))(["'])/gi, - (match, before, filename, ext, after) => { - const original = stockImages.get(filename.toLowerCase()); - if (original) { - return before + 'images/' + original + after; - } - return match; - }); - } - - // Expand <images> tags into gallery table - function expandImagesTag(html) { - return html.replace(/<images\s*\/?>/gi, () => { - const images = Array.from(stockImages.values()).sort(); - if (images.length === 0) return '<p>No images available</p>'; - const rows = images.map(filename => - `<tr><td>${filename}</td><td style="text-align:center;"><img src="images/${filename}"></td></tr>` - ).join(''); - return `<table border="1" cellpadding="8" cellspacing="0" style="width:100%;"><colgroup><col style="width:50%"><col style="width:50%"></colgroup>${rows}</table>`; - }); - } - const editorPane = document.querySelector('.editor-pane'); - const previewPane = document.querySelector('.preview-pane'); - const storageKey = 'html-lab-content'; - let isFullscreen = false; - let showLineNumbers = false; - let enableLineWrapping = false; - let lineNumbersCompartment; - let lineWrappingCompartment; - - function toggleFullscreen() { - - isFullscreen = !isFullscreen; - - if (isFullscreen) { - previewPane.classList.add('fullscreen'); - editorPane.classList.add('hidden'); - } else { - previewPane.classList.remove('fullscreen'); - editorPane.classList.remove('hidden'); - } - - // Update button title in iframe - try { - const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); - if (toggleButton) { - toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; - } - } catch (e) { - // Ignore cross-origin errors - } - } - - - // Listen for messages from iframe - window.addEventListener('message', function(event) { - if (event.data === 'toggleFullscreen') { - toggleFullscreen(); - } - }); - - function extractTitleAndFavicon(htmlCode) { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlCode, 'text/html'); - - // Extract title - const titleElement = doc.querySelector('title'); - const title = titleElement ? titleElement.textContent.trim() : null; - - // Extract favicon - const faviconSelectors = [ - 'link[rel="icon"]', - 'link[rel="shortcut icon"]', - 'link[rel="apple-touch-icon"]', - 'link[rel="mask-icon"]' - ]; - - let favicon = null; - for (const selector of faviconSelectors) { - const faviconElement = doc.querySelector(selector); - if (faviconElement && faviconElement.href) { - favicon = faviconElement.href; - break; - } - } - - return { title, favicon }; - } - - function updateMainPageTitleAndFavicon(title, favicon) { - // Update title - if (title) { - document.title = title; - } else { - document.title = 'Web Workshop'; - } - - // Update favicon - 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 = 'resources/rollerskate.png'; - } - } - - function updatePreview() { - // Skip preview updates while mobile keyboard is open and editor is focused - // This prevents keyboard layer resets on Android - 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); - - // Store scroll position before updating - let scrollX = 0, scrollY = 0; - try { - if (preview.contentWindow?.scrollX !== undefined) { - scrollX = preview.contentWindow.scrollX; - scrollY = preview.contentWindow.scrollY; - } - } catch (e) { - // Ignore cross-origin errors - } - - // Use srcdoc to create a completely fresh document context - // Expand <images> tag and rewrite bare stock image filenames - const processedCode = expandImagesTag(rewriteBareImageSrcs(code.trim())) || '<!DOCTYPE html><html><head></head><body></body></html>'; - preview.srcdoc = processedCode; - - // Add our functionality after the iframe loads - const onLoad = () => { - try { - const doc = preview.contentDocument; - if (!doc) return; - - // Add CSS for overscroll and 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'; - - // Use inline SVG to avoid being affected by user's img styles - 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); - } - - // Restore scroll position - setTimeout(() => { - try { - preview.contentWindow?.scrollTo(scrollX, scrollY); - } catch (e) { - // Ignore cross-origin errors - } - }, 10); - } catch (e) { - // Ignore cross-origin errors - } - - 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); - } - } - - function loadFromStorage() { - try { - return localStorage.getItem(storageKey) || ''; - } catch (e) { - console.warn('Could not load from localStorage:', e); - return ''; - } - } - - function loadEditorSettings() { - try { - showLineNumbers = localStorage.getItem('editor-line-numbers') === 'true'; - enableLineWrapping = localStorage.getItem('editor-line-wrapping') !== 'false'; - } catch (e) { - console.warn('Could not load editor settings from localStorage:', e); - showLineNumbers = false; - enableLineWrapping = true; - } - } - - function saveEditorSetting(key, value) { - try { - localStorage.setItem(key, value.toString()); - } catch (e) { - console.warn('Could not save editor setting to localStorage:', e); - } - } - - function toggleLineNumbers() { - const {lineNumbers} = window.CodeMirror; - showLineNumbers = !showLineNumbers; - saveEditorSetting('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; - - // Calculate indentation level (count leading whitespace) - let indentChars = 0; - for (let i = 0; i < lineText.length; i++) { - if (lineText[i] === '\t') { - indentChars += 2; // Convert tab to 2 spaces for calculation - } else if (lineText[i] === ' ') { - indentChars += 1; - } else { - break; - } - } - - // Apply hanging indent if line has indentation - 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('editor-line-wrapping', enableLineWrapping); - - const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; - - editorView.dispatch({ - effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) - }); - } - - // File operations - window.saveFile = function() { - const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'index.html'; - a.click(); - URL.revokeObjectURL(url); - }; - - 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(); - updatePreview(); - }; - reader.readAsText(file); - }; - input.click(); - }; - - // Wait for CodeMirror to be available - function initializeCodeMirror() { - if (!window.CodeMirror) { - setTimeout(initializeCodeMirror, 100); - return; - } - - const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration, DecorationSet} = window.CodeMirror; - - // Load saved content and editor settings - const savedContent = loadFromStorage(); - loadEditorSettings(); - - // Create compartments for dynamic extensions - lineNumbersCompartment = new Compartment(); - lineWrappingCompartment = new Compartment(); - - const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; - - // Create CodeMirror editor - editorView = new EditorView({ - state: EditorState.create({ - doc: savedContent, - extensions: [ - 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: "F1", run: () => { toggleLineNumbers(); return true; }}, - {key: "F2", run: () => { toggleLineWrapping(); return true; }}, - indentWithTab, - ...searchKeymap.filter(binding => binding.key !== "Mod-f"), - ...defaultKeymap - ]), - html(), - githubDark, - indentUnit.of("\t"), - placeholder("Build something with HTML..."), - EditorView.updateListener.of((update) => { - if (update.docChanged) { - clearTimeout(updateTimer); - updateTimer = setTimeout(updatePreview, 600); - saveToStorage(); - } - }), - // Disable text correction and autocomplete - EditorView.contentAttributes.of({ - 'autocomplete': 'off', - 'autocorrect': 'off', - 'autocapitalize': 'off', - 'spellcheck': 'false' - }), - lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), - lineWrappingCompartment.of(initialLineWrappingExtension) - ] - }), - parent: document.getElementById('editor') - }); - - // Initial render (wait for stock images manifest to load first) - stockImagesReady.then(() => updatePreview()); - - // Track editor focus and handle keyboard dismissal - editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; }); - - editorView.contentDOM.addEventListener('blur', () => { - isEditorFocused = false; - - // If we are on mobile and the keyboard mode is active, - // exit immediately. Do not wait for visualViewport resize. - if (isMobileDevice() && document.body.classList.contains('mobile-keyboard-open')) { - - // 1. Hide editor immediately to prevent the "stutter/jump" visual - editorPane.style.opacity = '0'; - - // 2. Remove the class to trigger layout engine (100% -> 50% height) - document.body.classList.remove('mobile-keyboard-open'); - - // 3. Wait for layout to settle, then restore scroll and opacity - requestAnimationFrame(() => requestAnimationFrame(() => { - if (editorView) { - // Scroll cursor into view in the new 50% layout - 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); - } - // Reveal the editor - editorPane.style.opacity = ''; - - // Update preview with any changes made while keyboard was open - updatePreview(); - })); - } - }); - - // Focus the editor - editorView.focus(); - - // Global keydown handler for Cmd+F toggle - document.addEventListener('keydown', function(e) { - if ((e.metaKey || e.ctrlKey) && e.key === 'f') { - e.preventDefault(); - closeSearchPanel(editorView) || openSearchPanel(editorView); - } - }); - } - - // Mobile keyboard detection - function isMobileDevice() { - return window.matchMedia("(pointer: coarse), (pointer: none)").matches; - } - - let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; - let isEditorFocused = false; - - 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) { - // Check if the class is still there (it might have been removed by blur already) - const wasKeyboardOpen = document.body.classList.contains('mobile-keyboard-open'); - - if (wasKeyboardOpen) { - // This is a fallback in case blur didn't catch it - // (e.g. if the keyboard was dismissed via a gesture that didn't blur immediately) - if (editorView) { - const pos = editorView.state.selection.main.head; - editorPane.style.opacity = '0'; - document.body.classList.remove('mobile-keyboard-open'); - - requestAnimationFrame(() => requestAnimationFrame(() => { - const lineBlock = editorView.lineBlockAt(pos); - const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); - editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); - editorPane.style.opacity = ''; - })); - } else { - document.body.classList.remove('mobile-keyboard-open'); - } - } - } - } - - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleViewportChange); - window.visualViewport.addEventListener('scroll', updateViewportVariables); - } else { - window.addEventListener('resize', handleViewportChange); - } - - // Initialize when page loads - initializeCodeMirror(); - - // Register service worker - if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - navigator.serviceWorker.register('./sw.js') - .then(registration => { - console.log('SW registered: ', registration); - }) - .catch(registrationError => { - console.log('SW registration failed: ', registrationError); - }); - }); - } - </script> + <script type="module" src="main.js"></script> </body> -</html> -\ No newline at end of file +</html> diff --git a/main.js b/main.js @@ -0,0 +1,529 @@ +let updateTimer; +let editorView; +const preview = document.getElementById('preview'); + +// Stock images loaded from manifest - can be referenced by bare filename +let stockImages = new Map(); // lowercase -> original filename + +// Load stock image list from manifest +const stockImagesReady = fetch('resource-manifest.json') + .then(r => r.json()) + .then(manifest => { + manifest.images.forEach(path => { + const filename = path.split('/').pop(); + stockImages.set(filename.toLowerCase(), filename); + }); + }) + .catch(() => console.log('Could not load resource manifest')); + +// Rewrite bare image filenames to use images/ prefix (case-insensitive, uses correct case) +function rewriteBareImageSrcs(html) { + return html.replace(/(<img\s[^>]*\bsrc\s*=\s*["'])([^"'/:]+\.(gif|png|jpg|jpeg|svg|webp))(["'])/gi, + (match, before, filename, ext, after) => { + const original = stockImages.get(filename.toLowerCase()); + if (original) { + return before + 'images/' + original + after; + } + return match; + }); +} + +// Expand <images> tags into gallery table +function expandImagesTag(html) { + return html.replace(/<images\s*\/?>/gi, () => { + const images = Array.from(stockImages.values()).sort(); + if (images.length === 0) return '<p>No images available</p>'; + const rows = images.map(filename => + `<tr><td>${filename}</td><td style="text-align:center;"><img src="images/${filename}"></td></tr>` + ).join(''); + return `<table border="1" cellpadding="8" cellspacing="0" style="width:100%;"><colgroup><col style="width:50%"><col style="width:50%"></colgroup>${rows}</table>`; + }); +} +const editorPane = document.querySelector('.editor-pane'); +const previewPane = document.querySelector('.preview-pane'); +const storageKey = 'html-lab-content'; +let isFullscreen = false; +let showLineNumbers = false; +let enableLineWrapping = false; +let lineNumbersCompartment; +let lineWrappingCompartment; + +function toggleFullscreen() { + + isFullscreen = !isFullscreen; + + if (isFullscreen) { + previewPane.classList.add('fullscreen'); + editorPane.classList.add('hidden'); + } else { + previewPane.classList.remove('fullscreen'); + editorPane.classList.remove('hidden'); + } + + // Update button title in iframe + try { + const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); + if (toggleButton) { + toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; + } + } catch (e) { + // Ignore cross-origin errors + } +} + + +// Listen for messages from iframe +window.addEventListener('message', function(event) { + if (event.data === 'toggleFullscreen') { + toggleFullscreen(); + } +}); + +function extractTitleAndFavicon(htmlCode) { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlCode, 'text/html'); + + // Extract title + const titleElement = doc.querySelector('title'); + const title = titleElement ? titleElement.textContent.trim() : null; + + // Extract favicon + const faviconSelectors = [ + 'link[rel="icon"]', + 'link[rel="shortcut icon"]', + 'link[rel="apple-touch-icon"]', + 'link[rel="mask-icon"]' + ]; + + let favicon = null; + for (const selector of faviconSelectors) { + const faviconElement = doc.querySelector(selector); + if (faviconElement && faviconElement.href) { + favicon = faviconElement.href; + break; + } + } + + return { title, favicon }; +} + +function updateMainPageTitleAndFavicon(title, favicon) { + // Update title + if (title) { + document.title = title; + } else { + document.title = 'Web Workshop'; + } + + // Update favicon + 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 = 'resources/rollerskate.png'; + } +} + +function updatePreview() { + // Skip preview updates while mobile keyboard is open and editor is focused + // This prevents keyboard layer resets on Android + 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); + + // Store scroll position before updating + let scrollX = 0, scrollY = 0; + try { + if (preview.contentWindow?.scrollX !== undefined) { + scrollX = preview.contentWindow.scrollX; + scrollY = preview.contentWindow.scrollY; + } + } catch (e) { + // Ignore cross-origin errors + } + + // Use srcdoc to create a completely fresh document context + // Expand <images> tag and rewrite bare stock image filenames + const processedCode = expandImagesTag(rewriteBareImageSrcs(code.trim())) || '<!DOCTYPE html><html><head></head><body></body></html>'; + preview.srcdoc = processedCode; + + // Add our functionality after the iframe loads + const onLoad = () => { + try { + const doc = preview.contentDocument; + if (!doc) return; + + // Add CSS for overscroll and 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'; + + // Use inline SVG to avoid being affected by user's img styles + 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); + } + + // Restore scroll position + setTimeout(() => { + try { + preview.contentWindow?.scrollTo(scrollX, scrollY); + } catch (e) { + // Ignore cross-origin errors + } + }, 10); + } catch (e) { + // Ignore cross-origin errors + } + + 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); + } +} + +function loadFromStorage() { + try { + return localStorage.getItem(storageKey) || ''; + } catch (e) { + console.warn('Could not load from localStorage:', e); + return ''; + } +} + +function loadEditorSettings() { + try { + showLineNumbers = localStorage.getItem('editor-line-numbers') === 'true'; + enableLineWrapping = localStorage.getItem('editor-line-wrapping') !== 'false'; + } catch (e) { + console.warn('Could not load editor settings from localStorage:', e); + showLineNumbers = false; + enableLineWrapping = true; + } +} + +function saveEditorSetting(key, value) { + try { + localStorage.setItem(key, value.toString()); + } catch (e) { + console.warn('Could not save editor setting to localStorage:', e); + } +} + +function toggleLineNumbers() { + const {lineNumbers} = window.CodeMirror; + showLineNumbers = !showLineNumbers; + saveEditorSetting('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; + + // Calculate indentation level (count leading whitespace) + let indentChars = 0; + for (let i = 0; i < lineText.length; i++) { + if (lineText[i] === '\t') { + indentChars += 2; // Convert tab to 2 spaces for calculation + } else if (lineText[i] === ' ') { + indentChars += 1; + } else { + break; + } + } + + // Apply hanging indent if line has indentation + 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('editor-line-wrapping', enableLineWrapping); + + const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; + + editorView.dispatch({ + effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) + }); +} + +// File operations +window.saveFile = function() { + const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'index.html'; + a.click(); + URL.revokeObjectURL(url); +}; + +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(); + updatePreview(); + }; + reader.readAsText(file); + }; + input.click(); +}; + +// Wait for CodeMirror to be available +function initializeCodeMirror() { + if (!window.CodeMirror) { + setTimeout(initializeCodeMirror, 100); + return; + } + + const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, undoDepth, redoDepth, history, historyKeymap, closeBrackets, closeBracketsKeymap, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers, Decoration, DecorationSet} = window.CodeMirror; + + // Load saved content and editor settings + const savedContent = loadFromStorage(); + loadEditorSettings(); + + // Create compartments for dynamic extensions + lineNumbersCompartment = new Compartment(); + lineWrappingCompartment = new Compartment(); + + const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; + + // Create CodeMirror editor + editorView = new EditorView({ + state: EditorState.create({ + doc: savedContent, + extensions: [ + 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: "F1", run: () => { toggleLineNumbers(); return true; }}, + {key: "F2", run: () => { toggleLineWrapping(); return true; }}, + indentWithTab, + ...searchKeymap.filter(binding => binding.key !== "Mod-f"), + ...defaultKeymap + ]), + html(), + githubDark, + indentUnit.of("\t"), + placeholder("Build something with HTML..."), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + clearTimeout(updateTimer); + updateTimer = setTimeout(updatePreview, 600); + saveToStorage(); + } + }), + // Disable text correction and autocomplete + EditorView.contentAttributes.of({ + 'autocomplete': 'off', + 'autocorrect': 'off', + 'autocapitalize': 'off', + 'spellcheck': 'false' + }), + lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), + lineWrappingCompartment.of(initialLineWrappingExtension) + ] + }), + parent: document.getElementById('editor') + }); + + // Initial render (wait for stock images manifest to load first) + stockImagesReady.then(() => updatePreview()); + + // Track editor focus and handle keyboard dismissal + editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; }); + + editorView.contentDOM.addEventListener('blur', () => { + isEditorFocused = false; + + // If we are on mobile and the keyboard mode is active, + // exit immediately. Do not wait for visualViewport resize. + if (isMobileDevice() && document.body.classList.contains('mobile-keyboard-open')) { + + // 1. Hide editor immediately to prevent the "stutter/jump" visual + editorPane.style.opacity = '0'; + + // 2. Remove the class to trigger layout engine (100% -> 50% height) + document.body.classList.remove('mobile-keyboard-open'); + + // 3. Wait for layout to settle, then restore scroll and opacity + requestAnimationFrame(() => requestAnimationFrame(() => { + if (editorView) { + // Scroll cursor into view in the new 50% layout + 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); + } + // Reveal the editor + editorPane.style.opacity = ''; + + // Update preview with any changes made while keyboard was open + updatePreview(); + })); + } + }); + + // Focus the editor + editorView.focus(); + + // Global keydown handler for Cmd+F toggle + document.addEventListener('keydown', function(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + closeSearchPanel(editorView) || openSearchPanel(editorView); + } + }); +} + +// Mobile keyboard detection +function isMobileDevice() { + return window.matchMedia("(pointer: coarse), (pointer: none)").matches; +} + +let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; +let isEditorFocused = false; + +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) { + // Check if the class is still there (it might have been removed by blur already) + const wasKeyboardOpen = document.body.classList.contains('mobile-keyboard-open'); + + if (wasKeyboardOpen) { + // This is a fallback in case blur didn't catch it + // (e.g. if the keyboard was dismissed via a gesture that didn't blur immediately) + if (editorView) { + const pos = editorView.state.selection.main.head; + editorPane.style.opacity = '0'; + document.body.classList.remove('mobile-keyboard-open'); + + requestAnimationFrame(() => requestAnimationFrame(() => { + const lineBlock = editorView.lineBlockAt(pos); + const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); + editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); + editorPane.style.opacity = ''; + })); + } else { + document.body.classList.remove('mobile-keyboard-open'); + } + } + } +} + +if (window.visualViewport) { + window.visualViewport.addEventListener('resize', handleViewportChange); + window.visualViewport.addEventListener('scroll', updateViewportVariables); +} else { + window.addEventListener('resize', handleViewportChange); +} + +// Initialize when page loads +initializeCodeMirror(); + +// Register service worker +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js') + .then(registration => { + console.log('SW registered: ', registration); + }) + .catch(registrationError => { + console.log('SW registration failed: ', registrationError); + }); + }); +} diff --git a/styles.css b/styles.css @@ -0,0 +1,205 @@ +:root { + --text-color: #f8f8f2; + --editor-bg: #16181b; + scrollbar-color: grey var(--editor-bg); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + overscroll-behavior: none; + background-color: var(--editor-bg); +} + +html { + height: 100%; +} + +body { + font-family: monospace; + background: var(--bg-color); + color: var(--text-color); + height: 100%; + display: flex; + overflow: hidden; +} + +.editor-pane { + width: 50%; + height: 100%; + position: relative; + transition: opacity 0.1s ease-out; /* Added transition for smoother opacity hack */ +} + +.preview-pane { + width: 50%; + height: 100%; + position: relative; +} + +.preview-pane.fullscreen { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; + background: white; +} + +/* Hide editor when preview is fullscreen */ +.editor-pane.hidden { + display: none; +} + +#editor { + width: 100%; + height: 100%; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 21px; +} + +/* CodeMirror styling */ +.cm-editor { + width: 100%; + height: 100%; + background: var(--editor-bg); + color: var(--text-color); +} + +.cm-placeholder { + color: #666; + opacity: 0.6; +} + +.cm-content { + padding: 15px; + font-size: 21px; + line-height: 1.6; +} + +.cm-gutters { + font-size: 21px; + line-height: 1.6; + user-select: none; +} + +.cm-lineNumbers .cm-gutterElement { + line-height: 1.6; + font-size: 21px; +} + +.ͼ1 .cm-lineNumbers .cm-gutterElement { + padding-right: 0; + padding-left: 8px; +} + +.cm-focused { + outline: none; +} + +/* Force tab size to 2 spaces - override theme defaults */ +.cm-editor .cm-content, +.cm-editor .cm-line, +.cm-editor { + tab-size: 2 !important; + -moz-tab-size: 2 !important; +} + +/* Disable overscroll on all CodeMirror elements */ +.cm-editor, .cm-content, .cm-scroller, .cm-scrollElement { + overscroll-behavior: none !important; +} + +/* Simplify search panel - hide all buttons, labels, and replace input */ +.cm-search button { + display: none !important; +} + +.cm-search { + background-color: #16181b; +} + +.cm-search label { + display: none !important; +} + +.cm-search input[name="replace"] { + display: none !important; +} + +/* Style search input to match editor text size */ +.cm-search input[name="search"] { + font-size: 21px !important; + width: 100% !important; + border-radius: 50px !important; + padding-left: 0.8em !important; + padding-right: 0.8em !important; + padding-bottom: 7px !important; +} + +.cm-panels-bottom { + border-radius: 0px !important; +} + +.cm-panels-bottom { + border-top: none !important; + +} + +#preview { + width: 100%; + height: 100%; + border: none; + background: white; + overflow: auto; +} + +/* Vertical layout for narrow screens (height > width) */ +@media (max-aspect-ratio: 1/1) { + body { + flex-direction: column; + } + + .editor-pane { + width: 100%; + height: 50%; + order: 2; + } + + .preview-pane { + width: 100%; + height: 50%; + order: 1; + padding-top: env(safe-area-inset-top); + } +} + +/* Mobile: fullscreen editor when keyboard is open */ +@media (pointer: coarse), (pointer: none) { + body.mobile-keyboard-open .preview-pane { + display: none; + } + + body.mobile-keyboard-open .editor-pane { + position: fixed; + top: calc(var(--visual-viewport-offset-top, 0px)); + left: 0; + width: 100%; + height: var(--visual-viewport-height, 100%); + max-height: var(--visual-viewport-height, 100%); + order: 1; + overscroll-behavior: none; + overflow: hidden; + } + + body.mobile-keyboard-open .editor-pane #editor, + body.mobile-keyboard-open .editor-pane .cm-editor { + height: 100% !important; + max-height: 100% !important; + } +}