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 }