<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <title>SVG viewer</title> <style> body { margin: 0; padding: 10px; font-family: system-ui, -apple-system, sans-serif; min-height: 100vh; /* Prevent content shift on mobile when keyboard appears */ min-height: -webkit-fill-available; } .container { display: flex; gap: 10px; height: calc(100vh - 20px); /* Support for mobile browsers */ height: calc(100vh - 20px - env(safe-area-inset-bottom)); } .editor, .preview { flex: 1; display: flex; flex-direction: column; min-height: 200px; /* Ensure minimum height on mobile */ } textarea { flex: 1; resize: none; padding: 10px; font-family: monospace; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; /* Improve mobile experience */ -webkit-overflow-scrolling: touch; /* Prevent zoom on mobile devices */ font-size: 16px; } canvas { flex: 1; border: 1px solid #ccc; border-radius: 4px; background: white; } h2 { margin-top: 0; margin-bottom: 10px; font-size: 16px; } .toolbar { display: flex; gap: 8px; margin-bottom: 16px; /* Increased margin for more space below */ flex-wrap: wrap; justify-content: space-between; /* Align error message to the right */ } .toolbar button { /* Improve touch targets */ min-height: 44px; padding: 8px 16px; /* Prevent text wrapping inside button */ white-space: nowrap; /* Add visual feedback for touch */ transition: background-color 0.2s; -webkit-touch-callout: none; user-select: none; border: 1px solid #ccc; border-radius: 6px; background: #f8f8f8; } .toolbar button:active { background-color: #e0e0e0; } @media (min-width: 1024px) { .toolbar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; -ms-overflow-style: none; scroll-snap-type: x mandatory; } .toolbar::-webkit-scrollbar { display: none; } .toolbar button { scroll-snap-align: start; } } /* Medium and small screens - stacked buttons */ @media (max-width: 1023px) { .toolbar { flex-direction: column; width: 100%; gap: 4px; } .toolbar button { width: 100%; text-align: left; justify-content: flex-start; padding: 12px 16px; } } /* Small mobile optimization */ @media (max-width: 480px) { .toolbar button { padding: 10px 14px; font-size: 14px; } } /* Landscape mode optimization */ @media (orientation: landscape) and (max-height: 500px) { .toolbar { /* Revert to horizontal scroll for landscape */ flex-direction: row; flex-wrap: nowrap; overflow-x: auto; } .toolbar button { width: auto; padding: 8px 16px; background-image: none; } } /* Media query for mobile devices */ @media (max-width: 768px) { .container { flex-direction: column; height: auto; min-height: 100vh; } .editor, .preview { flex: none; height: calc(50vh - 30px); /* Half viewport minus some spacing */ overflow: hidden; /* Contain scrolling children */ display: flex; flex-direction: column; } .toolbar { flex-direction: column; width: 100%; gap: 12px; /* Increased gap for more space between buttons */ } textarea, canvas { min-height: 200px; /* Minimum height */ height: 100%; overflow-y: auto; /* Enable vertical scrolling */ } h2 { font-size: 14px; margin-bottom: 8px; flex-shrink: 0; /* Prevent header from shrinking */ } } /* Landscape mode optimization */ @media (orientation: landscape) { .container { flex-direction: row; height: calc(100vh - 20px); } .editor, .preview { width: 50%; height: 100%; overflow: hidden; /* Contain scrolling children */ } .toolbar { flex-direction: row; flex-wrap: wrap; flex-shrink: 0; /* Prevent toolbar from shrinking */ } } /* Support for notched phones */ @supports (padding: max(0px)) { body { padding-left: max(10px, env(safe-area-inset-left)); padding-right: max(10px, env(safe-area-inset-right)); padding-bottom: max(10px, env(safe-area-inset-bottom)); } } /* Focus styles for better keyboard navigation */ .toolbar button:focus { outline: 2px solid #0066cc; outline-offset: 2px; } /* High contrast focus indicator for canvas */ canvas:focus { outline: 3px solid #0066cc; } /* Error message styling */ .error-message { color: #cc0000; font-size: 14px; margin-top: 4px; display: none; flex-grow: 1; /* Allow error message to take available space */ text-align: right; /* Align text to the right */ } /* Enhance focus visibility for WCAG 2.4.7 */ *:focus { outline: 3px solid #0066cc; outline-offset: 2px; } /* Add focus-visible for modern browsers */ *:focus:not(:focus-visible) { outline: none; } *:focus-visible { outline: 3px solid #0066cc; outline-offset: 2px; } /* High contrast mode support */ @media (forced-colors: active) { .toolbar button { border: 1px solid ButtonBorder; background: ButtonFace; color: ButtonText; } canvas, textarea { border: 1px solid ButtonBorder; } } .export-buttons { display: flex; gap: 8px; margin-top: 8px; } .export-buttons button { min-height: 44px; padding: 8px 16px; white-space: nowrap; transition: background-color 0.2s; -webkit-touch-callout: none; user-select: none; border: 1px solid #ccc; border-radius: 6px; background: #f8f8f8; } .export-buttons button:active { background-color: #e0e0e0; } </style> </head> <body> <main> <div class="container" role="application" aria-label="SVG Editor Application"> <div class="editor"> <h2 id="editor-label">SVG Editor</h2> <div class="toolbar" role="toolbar" aria-label="Error message"> <div id="error-message" class="error-message" role="alert"></div> </div> <textarea id="svgInput" spellcheck="false" aria-labelledby="editor-label" aria-describedby="editor-desc error-message" role="textbox" ><svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"> <circle cx="100" cy="100" r="50" fill="blue" /> </svg></textarea> <div class="export-buttons"> <button onclick="exportSVG()" aria-label="Export as SVG file">Export SVG</button> <button onclick="exportPNG()" aria-label="Export as PNG image">Export PNG</button> </div> </div> <div class="preview"> <h2 id="preview-label">Preview</h2> <canvas id="preview" role="img" aria-labelledby="preview-label" tabindex="0" ></canvas> </div> </div> </main> <script> const textarea = document.getElementById('svgInput'); const canvas = document.getElementById('preview'); const ctx = canvas.getContext('2d'); // Function to convert SVG string to image data const svgToImage = (svgString) => { const blob = new Blob([svgString], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Failed to load SVG')); }; img.src = url; }); }; // Function to parse SVG and check for syntax errors const checkSVG = (svgString) => { const parser = new DOMParser(); const doc = parser.parseFromString(svgString, 'image/svg+xml'); const errorNode = doc.querySelector('parsererror'); return errorNode ? errorNode.textContent : null; }; // Function to update the preview const updatePreview = async () => { const errorMessage = document.getElementById('error-message'); const svgString = textarea.value; const error = checkSVG(svgString); if (error) { errorMessage.style.display = 'block'; errorMessage.textContent = `SVG Syntax Error: ${error}`; canvas.setAttribute('aria-label', 'Preview unavailable due to SVG error'); return; } try { const img = await svgToImage(svgString); // Set canvas size to match its display size const rect = canvas.getBoundingClientRect(); canvas.width = rect.width; canvas.height = rect.height; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); // Calculate scaling to fit SVG while maintaining aspect ratio const scale = Math.min( canvas.width / img.width, canvas.height / img.height ); // Center the image const x = (canvas.width - img.width * scale) / 2; const y = (canvas.height - img.height * scale) / 2; // Draw scaled image ctx.drawImage(img, x, y, img.width * scale, img.height * scale); errorMessage.style.display = 'none'; errorMessage.textContent = ''; canvas.setAttribute('aria-label', 'SVG preview showing current editor content'); } catch (error) { console.error('Error updating preview:', error); errorMessage.style.display = 'block'; errorMessage.textContent = 'Error in SVG syntax. Please check your markup.'; canvas.setAttribute('aria-label', 'Preview unavailable due to SVG error'); } }; // Update preview when text changes textarea.addEventListener('input', () => { updatePreview(); }); // Handle window resize window.addEventListener('resize', () => { updatePreview(); }); // Initial preview updatePreview(); // Template functions for SVG shapes const shapeTemplates = { rect: ' <rect x="10" y="10" width="100" height="80" fill="red" />\n', circle: ' <circle cx="100" cy="100" r="50" fill="blue" />\n', ellipse: ' <ellipse cx="100" cy="100" rx="80" ry="50" fill="green" />\n', line: ' <line x1="10" y1="10" x2="190" y2="190" stroke="black" stroke-width="2" />\n', path: ' <path d="M 10,10 L 100,10 L 100,100 Z" fill="purple" />\n', text: ' <text x="100" y="100" text-anchor="middle">Hello SVG</text>\n' }; // Function to insert template at cursor position const insertShape = (shapeType) => { const template = shapeTemplates[shapeType]; const start = textarea.selectionStart; const end = textarea.selectionEnd; const text = textarea.value; // Find the closing </svg> tag const closingTag = '</svg>'; const closingTagIndex = text.lastIndexOf(closingTag); if (closingTagIndex !== -1) { // Insert the new shape before the closing tag const newText = text.substring(0, closingTagIndex) + template + text.substring(closingTagIndex); textarea.value = newText; updatePreview(); // Set cursor after inserted template const newPosition = closingTagIndex + template.length; textarea.setSelectionRange(newPosition, newPosition); textarea.focus(); } }; // Keyboard handling for toolbar document.querySelector('.toolbar').addEventListener('keydown', (e) => { const buttons = Array.from(e.currentTarget.querySelectorAll('button')); const currentIndex = buttons.indexOf(document.activeElement); switch(e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); buttons[(currentIndex + 1) % buttons.length].focus(); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); buttons[(currentIndex - 1 + buttons.length) % buttons.length].focus(); break; } }); // Announce successful exports const announceExport = (format) => { const message = `${format} file exported successfully`; const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', 'polite'); announcement.style.position = 'absolute'; announcement.style.width = '1px'; announcement.style.height = '1px'; announcement.style.overflow = 'hidden'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => document.body.removeChild(announcement), 1000); }; // Update export functions const exportSVG = () => { const svgBlob = new Blob([textarea.value], { type: 'image/svg+xml' }); const url = URL.createObjectURL(svgBlob); const link = document.createElement('a'); link.href = url; link.download = 'drawing.svg'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); announceExport('SVG'); }; const exportPNG = () => { const tempCanvas = document.createElement('canvas'); const tempCtx = tempCanvas.getContext('2d'); // Create image from SVG svgToImage(textarea.value).then(img => { // Set canvas to SVG's native size tempCanvas.width = img.width; tempCanvas.height = img.height; // Draw with white background tempCtx.fillStyle = 'white'; tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); tempCtx.drawImage(img, 0, 0); // Export const url = tempCanvas.toDataURL('image/png'); const link = document.createElement('a'); link.href = url; link.download = 'drawing.png'; document.body.appendChild(link); link.click(); document.body.removeChild(link); announceExport('PNG'); }); }; </script> </body> </html>