main.js (19.3 KB)
1 let updateTimer; 2 let editorView; 3 const preview = document.getElementById('preview'); 4 5 // Stock images loaded from manifest - can be referenced by bare filename 6 let stockImages = new Map(); // lowercase -> original filename 7 8 // Load stock image list from manifest 9 const stockImagesReady = fetch('resources/resource-manifest.json') 10 .then(r => r.json()) 11 .then(manifest => { 12 manifest.images.forEach(path => { 13 const filename = path.split('/').pop(); 14 stockImages.set(filename.toLowerCase(), filename); 15 }); 16 }) 17 .catch(() => console.log('Could not load resource manifest')); 18 19 // Rewrite bare image filenames to use resources/images/ prefix (case-insensitive, uses correct case) 20 function rewriteBareImageSrcs(html) { 21 // Rewrite <img src="filename.ext"> 22 html = html.replace(/(<img\s[^>]*\bsrc\s*=\s*["'])([^"'/:]+\.(gif|png|jpg|jpeg|svg|webp))(["'])/gi, 23 (match, before, filename, ext, after) => { 24 const original = stockImages.get(filename.toLowerCase()); 25 if (original) { 26 return before + 'resources/images/' + original + after; 27 } 28 return match; 29 }); 30 // Rewrite url(filename.ext) in CSS 31 html = html.replace(/(url\(\s*["']?)([^"')/:]+\.(gif|png|jpg|jpeg|svg|webp))(["']?\s*\))/gi, 32 (match, before, filename, ext, after) => { 33 const original = stockImages.get(filename.toLowerCase()); 34 if (original) { 35 return before + 'resources/images/' + original + after; 36 } 37 return match; 38 }); 39 return html; 40 } 41 42 // Expand <img src="?"> tags into gallery table 43 function expandImagesTag(html) { 44 return html.replace(/<img\s+src\s*=\s*["']?\?["']?\s*\/?>/gi, () => { 45 const images = Array.from(stockImages.values()).sort(); 46 if (images.length === 0) return '<p>No images available</p>'; 47 const rows = images.map(filename => 48 `<tr class="stock-image-row" data-filename="${filename}" style="cursor:pointer;user-select:none;"><td>${filename}</td><td style="text-align:center;"><img src="resources/images/${filename}" style="max-width:100%;height:auto;pointer-events:none;"></td></tr>` 49 ).join(''); 50 return `<table class="stock-image-table" border="1" cellpadding="8" cellspacing="0" style="max-width:100%;box-sizing:border-box;table-layout:fixed;"><colgroup><col style="width:50%"><col style="width:50%"></colgroup>${rows}</table>`; 51 }); 52 } 53 const editorPane = document.querySelector('.editor-pane'); 54 const previewPane = document.querySelector('.preview-pane'); 55 const storageKey = 'html-lab-content'; 56 let isFullscreen = false; 57 let showLineNumbers = false; 58 let enableLineWrapping = false; 59 let lineNumbersCompartment; 60 let lineWrappingCompartment; 61 62 function toggleFullscreen() { 63 isFullscreen = !isFullscreen; 64 65 if (isFullscreen) { 66 previewPane.classList.add('fullscreen'); 67 editorPane.classList.add('hidden'); 68 // Set iframe height to actual visible height (fixes iOS Safari toolbar issue) 69 preview.style.height = window.innerHeight + 'px'; 70 } else { 71 previewPane.classList.remove('fullscreen'); 72 editorPane.classList.remove('hidden'); 73 preview.style.height = ''; 74 } 75 76 // Update button title in iframe 77 try { 78 const toggleButton = preview.contentDocument.getElementById('fullscreenToggle'); 79 if (toggleButton) { 80 toggleButton.title = isFullscreen ? 'Exit fullscreen' : 'Toggle fullscreen'; 81 } 82 } catch (e) { 83 // Ignore cross-origin errors 84 } 85 } 86 87 88 // Listen for messages from iframe 89 window.addEventListener('message', function(event) { 90 if (event.data === 'toggleFullscreen') { 91 toggleFullscreen(); 92 } 93 }); 94 95 function extractTitleAndFavicon(htmlCode) { 96 const parser = new DOMParser(); 97 const doc = parser.parseFromString(htmlCode, 'text/html'); 98 99 // Extract title 100 const titleElement = doc.querySelector('title'); 101 const title = titleElement ? titleElement.textContent.trim() : null; 102 103 // Extract favicon 104 const faviconSelectors = [ 105 'link[rel="icon"]', 106 'link[rel="shortcut icon"]', 107 'link[rel="apple-touch-icon"]', 108 'link[rel="mask-icon"]' 109 ]; 110 111 let favicon = null; 112 for (const selector of faviconSelectors) { 113 const faviconElement = doc.querySelector(selector); 114 if (faviconElement && faviconElement.href) { 115 favicon = faviconElement.href; 116 break; 117 } 118 } 119 120 return { title, favicon }; 121 } 122 123 function updateMainPageTitleAndFavicon(title, favicon) { 124 // Update title 125 if (title) { 126 document.title = title; 127 } else { 128 document.title = 'Web Workshop'; 129 } 130 131 // Update favicon 132 let faviconLink = document.querySelector('link[rel="icon"]'); 133 if (!faviconLink) { 134 faviconLink = document.createElement('link'); 135 faviconLink.rel = 'icon'; 136 document.head.appendChild(faviconLink); 137 } 138 139 if (favicon) { 140 faviconLink.href = favicon; 141 } else { 142 faviconLink.href = 'resources/icons/construction.png'; 143 } 144 } 145 146 function updatePreview() { 147 // Skip preview updates while mobile keyboard is open and editor is focused 148 // This prevents keyboard layer resets on Android 149 if (isMobileDevice() && isEditorFocused && document.body.classList.contains('mobile-keyboard-open')) { 150 return; 151 } 152 153 const code = editorView.state.doc.toString(); 154 155 // Extract and update title and favicon from user's HTML 156 const { title, favicon } = extractTitleAndFavicon(code); 157 updateMainPageTitleAndFavicon(title, favicon); 158 159 // Store scroll position before updating 160 let scrollX = 0, scrollY = 0; 161 try { 162 if (preview.contentWindow?.scrollX !== undefined) { 163 scrollX = preview.contentWindow.scrollX; 164 scrollY = preview.contentWindow.scrollY; 165 } 166 } catch (e) { 167 // Ignore cross-origin errors 168 } 169 170 // Use srcdoc to create a completely fresh document context 171 // Expand <images> tag and rewrite bare stock image filenames 172 const processedCode = expandImagesTag(rewriteBareImageSrcs(code.trim())) || '<!DOCTYPE html><html><head></head><body></body></html>'; 173 preview.srcdoc = processedCode; 174 175 // Add our functionality after the iframe loads 176 const onLoad = () => { 177 try { 178 const doc = preview.contentDocument; 179 if (!doc) return; 180 181 // Add CSS for overscroll and button 182 const style = doc.createElement('style'); 183 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; } }'; 184 doc.head.appendChild(style); 185 186 // Create fullscreen button 187 const existingButton = doc.getElementById('fullscreenToggle'); 188 if (existingButton) existingButton.remove(); 189 190 const button = doc.createElement('button'); 191 button.id = 'fullscreenToggle'; 192 button.className = 'iframe-fullscreen-toggle'; 193 button.title = 'Toggle fullscreen'; 194 195 // Use inline SVG to avoid being affected by user's img styles 196 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>'; 197 198 button.addEventListener('click', function() { 199 parent.postMessage('toggleFullscreen', '*'); 200 }); 201 202 if (doc.body) { 203 doc.body.appendChild(button); 204 } 205 206 // Add click handlers for stock image table rows 207 const stockImageRows = doc.querySelectorAll('.stock-image-row'); 208 stockImageRows.forEach(row => { 209 row.addEventListener('click', () => { 210 const filename = row.getAttribute('data-filename'); 211 if (!filename) return; 212 213 // Find and replace <img src="?"> in the editor 214 const editorContent = editorView.state.doc.toString(); 215 const imgPattern = /<img\s+src\s*=\s*["']?\?["']?\s*\/?>/i; 216 const match = editorContent.match(imgPattern); 217 218 if (match) { 219 const start = editorContent.indexOf(match[0]); 220 const end = start + match[0].length; 221 const replacement = `<img src="${filename}">`; 222 223 editorView.dispatch({ 224 changes: { from: start, to: end, insert: replacement } 225 }); 226 saveToStorage(); 227 updatePreview(); 228 } 229 }); 230 }); 231 232 // Restore scroll position 233 setTimeout(() => { 234 try { 235 preview.contentWindow?.scrollTo(scrollX, scrollY); 236 } catch (e) { 237 // Ignore cross-origin errors 238 } 239 }, 10); 240 } catch (e) { 241 // Ignore cross-origin errors 242 } 243 244 preview.removeEventListener('load', onLoad); 245 }; 246 preview.addEventListener('load', onLoad); 247 } 248 249 function saveToStorage() { 250 try { 251 localStorage.setItem(storageKey, editorView.state.doc.toString()); 252 } catch (e) { 253 console.warn('Could not save to localStorage:', e); 254 } 255 } 256 257 function loadFromStorage() { 258 try { 259 return localStorage.getItem(storageKey) || ''; 260 } catch (e) { 261 console.warn('Could not load from localStorage:', e); 262 return ''; 263 } 264 } 265 266 function loadEditorSettings() { 267 try { 268 showLineNumbers = localStorage.getItem('editor-line-numbers') === 'true'; 269 enableLineWrapping = localStorage.getItem('editor-line-wrapping') !== 'false'; 270 } catch (e) { 271 console.warn('Could not load editor settings from localStorage:', e); 272 showLineNumbers = false; 273 enableLineWrapping = true; 274 } 275 } 276 277 function saveEditorSetting(key, value) { 278 try { 279 localStorage.setItem(key, value.toString()); 280 } catch (e) { 281 console.warn('Could not save editor setting to localStorage:', e); 282 } 283 } 284 285 function toggleLineNumbers() { 286 const {lineNumbers} = window.CodeMirror; 287 showLineNumbers = !showLineNumbers; 288 saveEditorSetting('editor-line-numbers', showLineNumbers); 289 290 editorView.dispatch({ 291 effects: lineNumbersCompartment.reconfigure(showLineNumbers ? lineNumbers() : []) 292 }); 293 } 294 295 function createLineWrappingExtension() { 296 const {EditorView, Decoration} = window.CodeMirror; 297 298 return [ 299 EditorView.lineWrapping, 300 EditorView.decorations.of((view) => { 301 const decorations = []; 302 303 for (let {from, to} of view.visibleRanges) { 304 for (let pos = from; pos <= to;) { 305 const line = view.state.doc.lineAt(pos); 306 const lineText = line.text; 307 308 // Calculate indentation level (count leading whitespace) 309 let indentChars = 0; 310 for (let i = 0; i < lineText.length; i++) { 311 if (lineText[i] === '\t') { 312 indentChars += 2; // Convert tab to 2 spaces for calculation 313 } else if (lineText[i] === ' ') { 314 indentChars += 1; 315 } else { 316 break; 317 } 318 } 319 320 // Apply hanging indent if line has indentation 321 if (indentChars > 0) { 322 const indentDecoration = Decoration.line({ 323 attributes: { 324 style: `text-indent: -${indentChars}ch; padding-left: calc(${indentChars}ch + 6px);` 325 } 326 }); 327 decorations.push(indentDecoration.range(line.from)); 328 } 329 330 pos = line.to + 1; 331 } 332 } 333 334 return decorations.length > 0 ? Decoration.set(decorations) : Decoration.none; 335 }), 336 ]; 337 } 338 339 function toggleLineWrapping() { 340 enableLineWrapping = !enableLineWrapping; 341 saveEditorSetting('editor-line-wrapping', enableLineWrapping); 342 343 const lineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; 344 345 editorView.dispatch({ 346 effects: lineWrappingCompartment.reconfigure(lineWrappingExtension) 347 }); 348 } 349 350 // File operations 351 window.saveFile = function() { 352 const blob = new Blob([editorView.state.doc.toString()], { type: 'text/html' }); 353 const url = URL.createObjectURL(blob); 354 const a = document.createElement('a'); 355 a.href = url; 356 a.download = 'index.html'; 357 a.click(); 358 URL.revokeObjectURL(url); 359 }; 360 361 window.loadFile = function() { 362 const input = document.createElement('input'); 363 input.type = 'file'; 364 input.accept = '.html,.htm'; 365 input.onchange = function(event) { 366 const file = event.target.files[0]; 367 if (!file) return; 368 369 const reader = new FileReader(); 370 reader.onload = function(e) { 371 editorView.dispatch({ 372 changes: { from: 0, to: editorView.state.doc.length, insert: e.target.result } 373 }); 374 saveToStorage(); 375 updatePreview(); 376 }; 377 reader.readAsText(file); 378 }; 379 input.click(); 380 }; 381 382 // Wait for CodeMirror to be available 383 function initializeCodeMirror() { 384 if (!window.CodeMirror) { 385 setTimeout(initializeCodeMirror, 100); 386 return; 387 } 388 389 const {EditorView, EditorState, Compartment, keymap, defaultKeymap, indentWithTab, html, githubDark, indentUnit, placeholder, undo, redo, history, closeBrackets, search, searchKeymap, closeSearchPanel, openSearchPanel, lineNumbers} = window.CodeMirror; 390 391 // Custom phrase overrides for CodeMirror UI (search panel) 392 const customPhrases = EditorState.phrases.of({ 393 "Find": "Find..." 394 }); 395 396 // Load saved content and editor settings 397 const savedContent = loadFromStorage(); 398 loadEditorSettings(); 399 400 // Create compartments for dynamic extensions 401 lineNumbersCompartment = new Compartment(); 402 lineWrappingCompartment = new Compartment(); 403 404 const initialLineWrappingExtension = enableLineWrapping ? createLineWrappingExtension() : []; 405 406 // Create CodeMirror editor 407 editorView = new EditorView({ 408 state: EditorState.create({ 409 doc: savedContent, 410 extensions: [ 411 customPhrases, 412 history(), 413 search(), 414 closeBrackets(), 415 keymap.of([ 416 {key: "Mod-z", run: undo}, 417 {key: "Mod-y", run: redo}, 418 {key: "Mod-Shift-z", run: redo}, 419 {key: "Mod-o", run: () => { window.loadFile(); return true; }}, 420 {key: "Mod-s", run: () => { window.saveFile(); return true; }}, 421 {key: "F1", run: () => { toggleLineNumbers(); return true; }}, 422 {key: "F2", run: () => { toggleLineWrapping(); return true; }}, 423 indentWithTab, 424 ...searchKeymap.filter(binding => binding.key !== "Mod-f"), 425 ...defaultKeymap 426 ]), 427 html(), 428 // Auto-close <style> and <script> tags (not handled by default html() extension) 429 // Also expand <!> into HTML boilerplate 430 EditorView.inputHandler.of((view, from, to, text) => { 431 if (text !== '>') return false; 432 const before = view.state.doc.sliceString(Math.max(0, from - 20), from); 433 // Check for <!> boilerplate trigger 434 if (before.endsWith('<!')) { 435 const boilerplate = `<!DOCTYPE html> 436 <html> 437 \t<head> 438 \t\t<style> 439 \t\t\t 440 \t\t</style> 441 \t</head> 442 \t<body> 443 \t\t 444 \t</body> 445 </html>`; 446 const startPos = from - 2; 447 const cursorPos = startPos + boilerplate.indexOf('<body>') + 9; 448 view.dispatch({ 449 changes: { from: startPos, to: from, insert: boilerplate }, 450 selection: { anchor: cursorPos } 451 }); 452 return true; 453 } 454 // Check for <style> or <script> 455 const match = before.match(/<(style|script)(\s[^>]*)?$/i); 456 if (!match) return false; 457 const tagName = match[1].toLowerCase(); 458 const closingTag = `</${tagName}>`; 459 view.dispatch({ 460 changes: { from, to, insert: '>' + closingTag }, 461 selection: { anchor: from + 1 } 462 }); 463 return true; 464 }), 465 githubDark, 466 indentUnit.of("\t"), 467 placeholder("Build something with HTML..."), 468 EditorView.updateListener.of((update) => { 469 if (update.docChanged) { 470 clearTimeout(updateTimer); 471 updateTimer = setTimeout(updatePreview, 600); 472 saveToStorage(); 473 } 474 }), 475 // Disable text correction and autocomplete 476 EditorView.contentAttributes.of({ 477 'autocomplete': 'off', 478 'autocorrect': 'off', 479 'autocapitalize': 'off', 480 'spellcheck': 'false' 481 }), 482 lineNumbersCompartment.of(showLineNumbers ? lineNumbers() : []), 483 lineWrappingCompartment.of(initialLineWrappingExtension) 484 ] 485 }), 486 parent: document.getElementById('editor') 487 }); 488 489 // Initial render (wait for stock images manifest to load first) 490 stockImagesReady.then(() => updatePreview()); 491 492 // Track editor focus and handle keyboard dismissal 493 editorView.contentDOM.addEventListener('focus', () => { isEditorFocused = true; }); 494 495 editorView.contentDOM.addEventListener('blur', () => { 496 isEditorFocused = false; 497 498 // If we are on mobile and the keyboard mode is active, 499 // exit immediately. Do not wait for visualViewport resize. 500 if (isMobileDevice()) { 501 exitMobileKeyboardMode(); 502 } 503 }); 504 505 // Focus the editor 506 editorView.focus(); 507 508 // Global keydown handler for Cmd+F toggle 509 document.addEventListener('keydown', function(e) { 510 if ((e.metaKey || e.ctrlKey) && e.key === 'f') { 511 e.preventDefault(); 512 closeSearchPanel(editorView) || openSearchPanel(editorView); 513 } 514 }); 515 516 // Disable browser autocomplete on search panel inputs when they appear 517 const editorElement = document.getElementById('editor'); 518 const searchInputObserver = new MutationObserver((mutations) => { 519 for (const mutation of mutations) { 520 for (const node of mutation.addedNodes) { 521 if (node.nodeType === Node.ELEMENT_NODE) { 522 const searchInputs = node.querySelectorAll?.('.cm-search input[name="search"], .cm-search input[name="replace"]'); 523 searchInputs?.forEach(input => input.setAttribute('autocomplete', 'off')); 524 } 525 } 526 } 527 }); 528 searchInputObserver.observe(editorElement, { childList: true, subtree: true }); 529 } 530 531 // Mobile keyboard detection 532 function isMobileDevice() { 533 return window.matchMedia("(pointer: coarse), (pointer: none)").matches; 534 } 535 536 let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; 537 let isEditorFocused = false; 538 539 // Handle transition from fullscreen keyboard mode back to split view 540 function exitMobileKeyboardMode() { 541 if (!document.body.classList.contains('mobile-keyboard-open')) return; 542 543 // Hide editor immediately to prevent visual stutter/jump 544 editorPane.style.opacity = '0'; 545 document.body.classList.remove('mobile-keyboard-open'); 546 547 // Wait for layout to settle, then restore scroll and opacity 548 requestAnimationFrame(() => requestAnimationFrame(() => { 549 if (editorView) { 550 // Scroll cursor into view in the new 50% layout 551 const pos = editorView.state.selection.main.head; 552 const lineBlock = editorView.lineBlockAt(pos); 553 const targetScroll = lineBlock.top - (editorView.dom.clientHeight / 2); 554 editorView.scrollDOM.scrollTop = Math.max(0, targetScroll); 555 } 556 editorPane.style.opacity = ''; 557 558 // Update preview with any changes made while keyboard was open 559 updatePreview(); 560 })); 561 } 562 563 function updateViewportVariables() { 564 const vv = window.visualViewport; 565 if (vv) { 566 document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`); 567 document.documentElement.style.setProperty('--visual-viewport-offset-top', `${vv.offsetTop}px`); 568 } 569 } 570 571 function handleViewportChange() { 572 if (!isMobileDevice()) return; 573 574 const currentHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; 575 const heightDifference = initialViewportHeight - currentHeight; 576 const isKeyboardOpen = heightDifference > 150; 577 578 updateViewportVariables(); 579 580 if (isKeyboardOpen && isEditorFocused) { 581 document.body.classList.add('mobile-keyboard-open'); 582 } else if (!isKeyboardOpen) { 583 // Fallback for keyboard dismissal that doesn't trigger blur (e.g. Android) 584 exitMobileKeyboardMode(); 585 } 586 } 587 588 if (window.visualViewport) { 589 window.visualViewport.addEventListener('resize', handleViewportChange); 590 window.visualViewport.addEventListener('scroll', updateViewportVariables); 591 } else { 592 window.addEventListener('resize', handleViewportChange); 593 } 594 595 // Initialize when page loads 596 initializeCodeMirror(); 597 598 // Register service worker 599 if ('serviceWorker' in navigator) { 600 window.addEventListener('load', () => { 601 navigator.serviceWorker.register('./sw.js') 602 .then(registration => { 603 console.log('SW registered: ', registration); 604 }) 605 .catch(registrationError => { 606 console.log('SW registration failed: ', registrationError); 607 }); 608 }); 609 }