commit 1f20ff91d5870b2c2c8a99298999088809eecb10
parent 6f13a01208bd2b1a3d545cbf735290445ecca349
Author: Hunter
Date: Mon, 22 Dec 2025 21:43:47 -0500
pull js and css out of index.html
Diffstat:
| M | index.html | | | 748 | +------------------------------------------------------------------------------ |
| A | main.js | | | 529 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | styles.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;
+ }
+}