From 013313e390083715ce14193708ea9b058327fb1f Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Thu, 18 Jun 2026 15:03:54 +0530 Subject: [PATCH 1/8] feat: Add GeoJSON, TopoJSON, and ASCII STL interactive renderers --- desktop-app/prepare.js | 49 +++ desktop-app/resources/js/preview-worker.js | 21 + desktop-app/resources/js/script.js | 423 ++++++++++++++++++++- desktop-app/resources/styles.css | 169 +++++++- preview-worker.js | 21 + script.js | 423 ++++++++++++++++++++- styles.css | 169 +++++++- 7 files changed, 1271 insertions(+), 4 deletions(-) diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index 44b612b8..80f70e82 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -188,12 +188,61 @@ async function prepareOfflineDependencies() { downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2", path.join(fontDir, "bootstrap-icons.woff2"), null)); downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff", path.join(fontDir, "bootstrap-icons.woff"), null)); + // Create Leaflet images directory for offline map icons + const leafletImagesDir = path.join(LIBS_DIR, "images"); + fs.mkdirSync(leafletImagesDir, { recursive: true }); + // Dynamic / Lazy-loaded dependencies to download manually for offline capability const dynamicLibs = [ { url: "https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js", dest: path.join(LIBS_DIR, "abcjs-basic-min.js"), hash: "sha512-QJ21PAOSw5KSiQ12gnP74qwLRAEn9GZtrFI0yY1akCLLpcEaC7xwZ7BiONZ/7pyrfUADyh7sHnI3SYHifO+tmg==" + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js", + dest: path.join(LIBS_DIR, "leaflet.js"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css", + dest: path.join(LIBS_DIR, "leaflet.css"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png", + dest: path.join(leafletImagesDir, "marker-icon.png"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png", + dest: path.join(leafletImagesDir, "marker-icon-2x.png"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png", + dest: path.join(leafletImagesDir, "marker-shadow.png"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js", + dest: path.join(LIBS_DIR, "topojson.min.js"), + hash: null + }, + { + url: "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js", + dest: path.join(LIBS_DIR, "three.min.js"), + hash: null + }, + { + url: "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js", + dest: path.join(LIBS_DIR, "STLLoader.js"), + hash: null + }, + { + url: "https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js", + dest: path.join(LIBS_DIR, "OrbitControls.js"), + hash: null } ]; diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index 9cdef6cc..a5321767 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -4,6 +4,9 @@ let librariesLoaded = false; let markedConfigured = false; let mermaidIdCounter = 0; let abcIdCounter = 0; +let geojsonIdCounter = 0; +let topojsonIdCounter = 0; +let stlIdCounter = 0; const markedOptions = { gfm: true, @@ -318,6 +321,21 @@ function configureMarked() { return `
${escapeHtml(code)}
`; } + if (language === "geojson") { + const uniqueId = `geojson-map-worker-${geojsonIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + + if (language === "topojson") { + const uniqueId = `topojson-map-worker-${topojsonIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + + if (language === "stl") { + const uniqueId = `stl-viewer-worker-${stlIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + if (language === "math") { return `
$$\n${code}\n$$
\n`; } @@ -495,6 +513,9 @@ self.onmessage = function(event) { ensureLibraries(options.libraryUrls || {}); mermaidIdCounter = 0; abcIdCounter = 0; + geojsonIdCounter = 0; + topojsonIdCounter = 0; + stlIdCounter = 0; const result = renderSegmentedMarkdown(data.markdown || "", options); self.postMessage({ type: "render-result", diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 36e25ed0..22813e88 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -34,14 +34,29 @@ document.addEventListener("DOMContentLoaded", function () { pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js', joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js', joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css', - abcjs: 'https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js' + abcjs: 'https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js', + leaflet_css: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css', + leaflet_js: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js', + topojson: 'https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js', + three: 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js', + stlLoader: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js', + orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js' }; // Resolve local paths for desktop (Neutralinojs) offline support if (typeof Neutralino !== 'undefined') { CDN.abcjs = '/libs/abcjs-basic-min.js'; + CDN.leaflet_css = '/libs/leaflet.css'; + CDN.leaflet_js = '/libs/leaflet.js'; + CDN.topojson = '/libs/topojson.min.js'; + CDN.three = '/libs/three.min.js'; + CDN.stlLoader = '/libs/STLLoader.js'; + CDN.orbitControls = '/libs/OrbitControls.js'; } + // Active WebGL / Three.js 3D STL renderers Map for memory cleanup + const activeStlViews = new Map(); + let markdownRenderTimeout = null; let pendingPreviewRenderCancel = null; let previewRenderGeneration = 0; @@ -943,6 +958,33 @@ document.addEventListener("DOMContentLoaded", function () { return `
${escapedCode}
`; } + if (language === 'geojson') { + const uniqueId = 'geojson-map-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + + if (language === 'topojson') { + const uniqueId = 'topojson-map-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + + if (language === 'stl') { + const uniqueId = 'stl-viewer-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + if (language === 'math') { return `
$$\n${code}\n$$
\n`; } @@ -2145,9 +2187,292 @@ document.addEventListener("DOMContentLoaded", function () { }; } + function disposeStlView(viewId) { + const view = activeStlViews.get(viewId); + if (!view) return; + + if (view.animationFrameId) { + cancelAnimationFrame(view.animationFrameId); + } + if (view.controls) { + view.controls.dispose(); + } + if (view.scene) { + view.scene.traverse(node => { + if (node.geometry) { + node.geometry.dispose(); + } + if (node.material) { + if (Array.isArray(node.material)) { + node.material.forEach(mat => mat.dispose()); + } else { + node.material.dispose(); + } + } + }); + } + if (view.renderer) { + view.renderer.dispose(); + if (view.renderer.domElement && view.renderer.domElement.parentElement) { + view.renderer.domElement.parentElement.removeChild(view.renderer.domElement); + } + } + activeStlViews.delete(viewId); + } + + function renderMapNode(node, isTopo, context) { + const originalCode = node.getAttribute('data-original-code'); + if (!originalCode) return; + const decodedCode = decodeURIComponent(originalCode); + const container = node.closest('.geojson-container') || node.closest('.topojson-container'); + + try { + let geojsonData; + if (isTopo) { + const topology = JSON.parse(decodedCode); + if (topology && topology.objects) { + const features = []; + for (const key in topology.objects) { + if (Object.prototype.hasOwnProperty.call(topology.objects, key)) { + const feature = topojson.feature(topology, topology.objects[key]); + if (feature.type === 'FeatureCollection') { + features.push(...feature.features); + } else { + features.push(feature); + } + } + } + geojsonData = { + type: 'FeatureCollection', + features: features + }; + } + } else { + geojsonData = JSON.parse(decodedCode); + } + + if (!geojsonData) return; + + node.innerHTML = ''; + const map = L.map(node); + node._leafletMap = map; + + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + let tileUrl; + let tileAttribution = '© OpenStreetMap contributors'; + + if (currentTheme === 'dark') { + tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } else { + tileUrl = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } + + L.tileLayer(tileUrl, { + attribution: tileAttribution, + maxZoom: 19 + }).addTo(map); + + const geojsonLayer = L.geoJSON(geojsonData, { + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '
'; + let hasProps = false; + for (const key in feature.properties) { + if (Object.prototype.hasOwnProperty.call(feature.properties, key)) { + const val = feature.properties[key]; + const escapedKey = escapeHtml(String(key)); + const escapedVal = escapeHtml(String(typeof val === 'object' ? JSON.stringify(val) : val)); + popupContent += ``; + hasProps = true; + } + } + popupContent += '
${escapedKey}${escapedVal}
'; + if (hasProps) { + layer.bindPopup(popupContent); + } + } + } + }).addTo(map); + + const bounds = geojsonLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds); + } else { + map.setView([0, 0], 2); + } + + if (container) container.classList.remove('is-loading'); + } catch (err) { + console.error("Map rendering failed:", err); + node.innerHTML = `
Error rendering map: ${escapeHtml(err.message)}
`; + if (container) container.classList.remove('is-loading'); + } + } + + function renderStlNode(node, context) { + const originalCode = node.getAttribute('data-original-code'); + if (!originalCode) return; + const decodedCode = decodeURIComponent(originalCode); + const container = node.closest('.stl-container'); + const nodeId = node.id; + + if (activeStlViews.has(nodeId)) { + disposeStlView(nodeId); + } + + try { + node.innerHTML = ''; + const width = node.clientWidth || (container ? container.clientWidth : 400) || 400; + const height = 400; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + node.appendChild(renderer.domElement); + + const controls = new THREE.OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + + const ambientLight = new THREE.AmbientLight(0x404040, 1.5); + scene.add(ambientLight); + + const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight1.position.set(1, 1, 1).normalize(); + scene.add(dirLight1); + + const dirLight2 = new THREE.DirectionalLight(0x90caf9, 0.3); + dirLight2.position.set(-1, -1, -1).normalize(); + scene.add(dirLight2); + + const loader = new THREE.STLLoader(); + const geometry = loader.parse(new TextEncoder().encode(decodedCode).buffer); + + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + const matColor = currentTheme === 'dark' ? 0x90caf9 : 0x1976d2; + + const material = new THREE.MeshStandardMaterial({ + color: matColor, + roughness: 0.4, + metalness: 0.6 + }); + + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + geometry.computeBoundingBox(); + geometry.computeVertexNormals(); + + const boundingBox = geometry.boundingBox; + const center = new THREE.Vector3(); + boundingBox.getCenter(center); + mesh.position.sub(center); + + const size = new THREE.Vector3(); + boundingBox.getSize(size); + const maxDim = Math.max(size.x, size.y, size.z); + + const fov = camera.fov * (Math.PI / 180); + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); + cameraZ *= 1.4; + + camera.position.set(maxDim * 0.8, maxDim * 0.8, cameraZ); + camera.lookAt(0, 0, 0); + controls.target.set(0, 0, 0); + + camera.far = maxDim * 10; + camera.updateProjectionMatrix(); + + let animationFrameId; + const animate = function() { + animationFrameId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + + const activeView = activeStlViews.get(nodeId); + if (activeView) { + activeView.animationFrameId = animationFrameId; + } + }; + + activeStlViews.set(nodeId, { + container: node, + renderer: renderer, + scene: scene, + camera: camera, + controls: controls, + animationFrameId: null + }); + + animate(); + + if (container) container.classList.remove('is-loading'); + } catch (err) { + console.error("STL rendering failed:", err); + node.innerHTML = `
Error rendering 3D model: ${escapeHtml(err.message)}
`; + if (container) container.classList.remove('is-loading'); + } + } + + function updateMapThemes() { + if (typeof L === 'undefined') return; + const mapNodes = markdownPreview.querySelectorAll('.geojson-map, .topojson-map'); + mapNodes.forEach(node => { + const map = node._leafletMap; + if (map) { + map.eachLayer(layer => { + if (layer instanceof L.TileLayer) { + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + let tileUrl; + let tileAttribution = '© OpenStreetMap contributors'; + if (currentTheme === 'dark') { + tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } else { + tileUrl = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } + layer.setUrl(tileUrl); + layer.setAttribution(tileAttribution); + } + }); + } + }); + } + + function updateStlThemes() { + if (typeof THREE === 'undefined') return; + const stlNodes = markdownPreview.querySelectorAll('.stl-viewer'); + stlNodes.forEach(node => { + const view = activeStlViews.get(node.id); + if (view && view.scene) { + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + const matColor = currentTheme === 'dark' ? 0x90caf9 : 0x1976d2; + + view.scene.traverse(child => { + if (child instanceof THREE.Mesh && child.material) { + child.material.color.setHex(matColor); + child.material.needsUpdate = true; + } + }); + } + }); + } + function postProcessPreview(rawVal, context, patchResult) { const roots = getPreviewPostProcessRoots(patchResult); + // Clean up orphaned STL views that are no longer present in the document + activeStlViews.forEach((view, id) => { + if (!document.body.contains(view.container)) { + disposeStlView(id); + } + }); + roots.forEach(function(root) { processEmojis(root); }); @@ -2317,6 +2642,99 @@ document.addEventListener("DOMContentLoaded", function () { console.warn("ABC notation processing failed:", e); } + try { + const geojsonNodes = queryPreviewRoots(roots, '.geojson-map'); + const topojsonNodes = queryPreviewRoots(roots, '.topojson-map'); + + if (geojsonNodes.length > 0 || topojsonNodes.length > 0) { + const renderAllMaps = function() { + if (context.renderId !== previewRenderGeneration) return; + geojsonNodes.forEach(node => renderMapNode(node, false, context)); + topojsonNodes.forEach(node => renderMapNode(node, true, context)); + }; + + const promises = []; + if (typeof L === 'undefined') { + promises.push(loadStyle(CDN.leaflet_css)); + promises.push(loadScript(CDN.leaflet_js)); + } + if (topojsonNodes.length > 0 && typeof topojson === 'undefined') { + promises.push(loadScript(CDN.topojson)); + } + + if (promises.length > 0) { + Promise.all(promises).then(function() { + renderAllMaps(); + }).catch(function(e) { + console.warn('Failed to load map libraries:', e); + geojsonNodes.concat(topojsonNodes).forEach(node => { + const container = node.closest('.geojson-container') || node.closest('.topojson-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } else { + renderAllMaps(); + } + } + } catch (e) { + console.warn("GeoJSON/TopoJSON processing failed:", e); + } + + try { + const stlNodes = queryPreviewRoots(roots, '.stl-viewer'); + if (stlNodes.length > 0) { + const renderAllStls = function() { + if (context.renderId !== previewRenderGeneration) return; + stlNodes.forEach(node => renderStlNode(node, context)); + }; + + const promises = []; + if (typeof THREE === 'undefined') { + promises.push(loadScript(CDN.three)); + } + + const loadLoaderAndControls = function() { + const subPromises = []; + if (typeof THREE.STLLoader === 'undefined') { + subPromises.push(loadScript(CDN.stlLoader)); + } + if (typeof THREE.OrbitControls === 'undefined') { + subPromises.push(loadScript(CDN.orbitControls)); + } + if (subPromises.length > 0) { + return Promise.all(subPromises); + } + return Promise.resolve(); + }; + + if (typeof THREE === 'undefined') { + loadScript(CDN.three).then(function() { + return loadLoaderAndControls(); + }).then(function() { + renderAllStls(); + }).catch(function(e) { + console.warn('Failed to load Three.js libraries:', e); + stlNodes.forEach(node => { + const container = node.closest('.stl-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } else { + loadLoaderAndControls().then(function() { + renderAllStls(); + }).catch(function(e) { + console.warn('Failed to load Three.js addons:', e); + stlNodes.forEach(node => { + const container = node.closest('.stl-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } + } + } catch (e) { + console.warn("STL processing failed:", e); + } + const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '') || /```math\b/.test(rawVal || ''); if (hasMath) { const typesetTargets = roots.filter(function(root) { @@ -6737,6 +7155,9 @@ document.addEventListener("DOMContentLoaded", function () { console.warn('Mermaid theme re-render failed:', e); } } + + updateMapThemes(); + updateStlThemes(); }); async function nativeSaveMarkdown() { diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index a6191ae4..7d14faef 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -3943,6 +3943,167 @@ html[lang="ko"] .markdown-body h1, html[lang="ko"] .markdown-body h2, html[lang= border: 0; } +/* --- GeoJSON and TopoJSON Map Styles --- */ +.geojson-container, .topojson-container { + display: flex; + flex-direction: column; + margin: 1.5em 0; + padding: 0.5em; + background-color: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.geojson-container.is-loading, .topojson-container.is-loading { + min-height: 400px; + background-color: var(--skeleton-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; + animation: skeleton-pulse 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; +} + +.geojson-container.is-loading .geojson-map, +.topojson-container.is-loading .topojson-map { + opacity: 0; +} + +.geojson-container.is-loading::after, +.topojson-container.is-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + var(--skeleton-glow) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: skeleton-shimmer 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.geojson-map, .topojson-map { + width: 100%; + height: 400px; + border-radius: 4px; + background: transparent; +} + +/* Map Popup Styling */ +.map-popup-container { + max-width: 300px; + max-height: 200px; + overflow-y: auto; + font-family: var(--font-sans); + font-size: 13px; + color: var(--text-color, #333); +} + +.leaflet-popup-content-wrapper { + background-color: var(--code-bg) !important; + color: var(--text-color) !important; + border: 1px solid var(--border-color) !important; + border-radius: 6px !important; +} + +.leaflet-popup-tip { + background-color: var(--code-bg) !important; + border: 1px solid var(--border-color) !important; +} + +.map-popup-table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.map-popup-table td { + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); + vertical-align: top; + word-break: break-word; +} + +.map-popup-table tr:last-child td { + border-bottom: none; +} + +.map-popup-table td.prop-key { + color: var(--accent-color, #0969da); + font-weight: 600; + padding-right: 12px; +} + +.map-popup-table td.prop-val { + color: var(--text-color); +} + +/* --- STL 3D Viewer Styles --- */ +.stl-container { + display: flex; + flex-direction: column; + margin: 1.5em 0; + padding: 0.5em; + background-color: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + position: relative; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.stl-container.is-loading { + min-height: 400px; + background-color: var(--skeleton-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; + animation: skeleton-pulse 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; +} + +.stl-container.is-loading .stl-viewer { + opacity: 0; +} + +.stl-container.is-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + var(--skeleton-glow) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: skeleton-shimmer 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.stl-viewer { + width: 100%; + height: 400px; + border-radius: 4px; + outline: none; +} + +.stl-viewer canvas { + display: block; +} + +.stl-toolbar { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 4px 8px; + background-color: var(--editor-bg); + border-bottom: 1px solid var(--border-color); +} + /* Accessibility: respect user's motion preferences */ @media (prefers-reduced-motion: reduce) { .skeleton-placeholder, @@ -3950,7 +4111,13 @@ html[lang="ko"] .markdown-body h1, html[lang="ko"] .markdown-body h2, html[lang= .mermaid-container.is-loading, .mermaid-container.is-loading::after, .abc-container.is-loading, - .abc-container.is-loading::after { + .abc-container.is-loading::after, + .geojson-container.is-loading, + .geojson-container.is-loading::after, + .topojson-container.is-loading, + .topojson-container.is-loading::after, + .stl-container.is-loading, + .stl-container.is-loading::after { animation: none; } .drag-overlay-inner { diff --git a/preview-worker.js b/preview-worker.js index 9cdef6cc..a5321767 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -4,6 +4,9 @@ let librariesLoaded = false; let markedConfigured = false; let mermaidIdCounter = 0; let abcIdCounter = 0; +let geojsonIdCounter = 0; +let topojsonIdCounter = 0; +let stlIdCounter = 0; const markedOptions = { gfm: true, @@ -318,6 +321,21 @@ function configureMarked() { return `
${escapeHtml(code)}
`; } + if (language === "geojson") { + const uniqueId = `geojson-map-worker-${geojsonIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + + if (language === "topojson") { + const uniqueId = `topojson-map-worker-${topojsonIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + + if (language === "stl") { + const uniqueId = `stl-viewer-worker-${stlIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + if (language === "math") { return `
$$\n${code}\n$$
\n`; } @@ -495,6 +513,9 @@ self.onmessage = function(event) { ensureLibraries(options.libraryUrls || {}); mermaidIdCounter = 0; abcIdCounter = 0; + geojsonIdCounter = 0; + topojsonIdCounter = 0; + stlIdCounter = 0; const result = renderSegmentedMarkdown(data.markdown || "", options); self.postMessage({ type: "render-result", diff --git a/script.js b/script.js index 36e25ed0..22813e88 100644 --- a/script.js +++ b/script.js @@ -34,14 +34,29 @@ document.addEventListener("DOMContentLoaded", function () { pako: 'https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js', joypixels: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/lib/js/joypixels.min.js', joypixels_css: 'https://cdn.jsdelivr.net/npm/emoji-toolkit@9.0.1/extras/css/joypixels.min.css', - abcjs: 'https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js' + abcjs: 'https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js', + leaflet_css: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css', + leaflet_js: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js', + topojson: 'https://cdnjs.cloudflare.com/ajax/libs/topojson/3.0.2/topojson.min.js', + three: 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js', + stlLoader: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js', + orbitControls: 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js' }; // Resolve local paths for desktop (Neutralinojs) offline support if (typeof Neutralino !== 'undefined') { CDN.abcjs = '/libs/abcjs-basic-min.js'; + CDN.leaflet_css = '/libs/leaflet.css'; + CDN.leaflet_js = '/libs/leaflet.js'; + CDN.topojson = '/libs/topojson.min.js'; + CDN.three = '/libs/three.min.js'; + CDN.stlLoader = '/libs/STLLoader.js'; + CDN.orbitControls = '/libs/OrbitControls.js'; } + // Active WebGL / Three.js 3D STL renderers Map for memory cleanup + const activeStlViews = new Map(); + let markdownRenderTimeout = null; let pendingPreviewRenderCancel = null; let previewRenderGeneration = 0; @@ -943,6 +958,33 @@ document.addEventListener("DOMContentLoaded", function () { return `
${escapedCode}
`; } + if (language === 'geojson') { + const uniqueId = 'geojson-map-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + + if (language === 'topojson') { + const uniqueId = 'topojson-map-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + + if (language === 'stl') { + const uniqueId = 'stl-viewer-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } + if (language === 'math') { return `
$$\n${code}\n$$
\n`; } @@ -2145,9 +2187,292 @@ document.addEventListener("DOMContentLoaded", function () { }; } + function disposeStlView(viewId) { + const view = activeStlViews.get(viewId); + if (!view) return; + + if (view.animationFrameId) { + cancelAnimationFrame(view.animationFrameId); + } + if (view.controls) { + view.controls.dispose(); + } + if (view.scene) { + view.scene.traverse(node => { + if (node.geometry) { + node.geometry.dispose(); + } + if (node.material) { + if (Array.isArray(node.material)) { + node.material.forEach(mat => mat.dispose()); + } else { + node.material.dispose(); + } + } + }); + } + if (view.renderer) { + view.renderer.dispose(); + if (view.renderer.domElement && view.renderer.domElement.parentElement) { + view.renderer.domElement.parentElement.removeChild(view.renderer.domElement); + } + } + activeStlViews.delete(viewId); + } + + function renderMapNode(node, isTopo, context) { + const originalCode = node.getAttribute('data-original-code'); + if (!originalCode) return; + const decodedCode = decodeURIComponent(originalCode); + const container = node.closest('.geojson-container') || node.closest('.topojson-container'); + + try { + let geojsonData; + if (isTopo) { + const topology = JSON.parse(decodedCode); + if (topology && topology.objects) { + const features = []; + for (const key in topology.objects) { + if (Object.prototype.hasOwnProperty.call(topology.objects, key)) { + const feature = topojson.feature(topology, topology.objects[key]); + if (feature.type === 'FeatureCollection') { + features.push(...feature.features); + } else { + features.push(feature); + } + } + } + geojsonData = { + type: 'FeatureCollection', + features: features + }; + } + } else { + geojsonData = JSON.parse(decodedCode); + } + + if (!geojsonData) return; + + node.innerHTML = ''; + const map = L.map(node); + node._leafletMap = map; + + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + let tileUrl; + let tileAttribution = '© OpenStreetMap contributors'; + + if (currentTheme === 'dark') { + tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } else { + tileUrl = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } + + L.tileLayer(tileUrl, { + attribution: tileAttribution, + maxZoom: 19 + }).addTo(map); + + const geojsonLayer = L.geoJSON(geojsonData, { + onEachFeature: function(feature, layer) { + if (feature.properties) { + let popupContent = '
'; + let hasProps = false; + for (const key in feature.properties) { + if (Object.prototype.hasOwnProperty.call(feature.properties, key)) { + const val = feature.properties[key]; + const escapedKey = escapeHtml(String(key)); + const escapedVal = escapeHtml(String(typeof val === 'object' ? JSON.stringify(val) : val)); + popupContent += ``; + hasProps = true; + } + } + popupContent += '
${escapedKey}${escapedVal}
'; + if (hasProps) { + layer.bindPopup(popupContent); + } + } + } + }).addTo(map); + + const bounds = geojsonLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds); + } else { + map.setView([0, 0], 2); + } + + if (container) container.classList.remove('is-loading'); + } catch (err) { + console.error("Map rendering failed:", err); + node.innerHTML = `
Error rendering map: ${escapeHtml(err.message)}
`; + if (container) container.classList.remove('is-loading'); + } + } + + function renderStlNode(node, context) { + const originalCode = node.getAttribute('data-original-code'); + if (!originalCode) return; + const decodedCode = decodeURIComponent(originalCode); + const container = node.closest('.stl-container'); + const nodeId = node.id; + + if (activeStlViews.has(nodeId)) { + disposeStlView(nodeId); + } + + try { + node.innerHTML = ''; + const width = node.clientWidth || (container ? container.clientWidth : 400) || 400; + const height = 400; + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + node.appendChild(renderer.domElement); + + const controls = new THREE.OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + + const ambientLight = new THREE.AmbientLight(0x404040, 1.5); + scene.add(ambientLight); + + const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8); + dirLight1.position.set(1, 1, 1).normalize(); + scene.add(dirLight1); + + const dirLight2 = new THREE.DirectionalLight(0x90caf9, 0.3); + dirLight2.position.set(-1, -1, -1).normalize(); + scene.add(dirLight2); + + const loader = new THREE.STLLoader(); + const geometry = loader.parse(new TextEncoder().encode(decodedCode).buffer); + + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + const matColor = currentTheme === 'dark' ? 0x90caf9 : 0x1976d2; + + const material = new THREE.MeshStandardMaterial({ + color: matColor, + roughness: 0.4, + metalness: 0.6 + }); + + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + geometry.computeBoundingBox(); + geometry.computeVertexNormals(); + + const boundingBox = geometry.boundingBox; + const center = new THREE.Vector3(); + boundingBox.getCenter(center); + mesh.position.sub(center); + + const size = new THREE.Vector3(); + boundingBox.getSize(size); + const maxDim = Math.max(size.x, size.y, size.z); + + const fov = camera.fov * (Math.PI / 180); + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); + cameraZ *= 1.4; + + camera.position.set(maxDim * 0.8, maxDim * 0.8, cameraZ); + camera.lookAt(0, 0, 0); + controls.target.set(0, 0, 0); + + camera.far = maxDim * 10; + camera.updateProjectionMatrix(); + + let animationFrameId; + const animate = function() { + animationFrameId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + + const activeView = activeStlViews.get(nodeId); + if (activeView) { + activeView.animationFrameId = animationFrameId; + } + }; + + activeStlViews.set(nodeId, { + container: node, + renderer: renderer, + scene: scene, + camera: camera, + controls: controls, + animationFrameId: null + }); + + animate(); + + if (container) container.classList.remove('is-loading'); + } catch (err) { + console.error("STL rendering failed:", err); + node.innerHTML = `
Error rendering 3D model: ${escapeHtml(err.message)}
`; + if (container) container.classList.remove('is-loading'); + } + } + + function updateMapThemes() { + if (typeof L === 'undefined') return; + const mapNodes = markdownPreview.querySelectorAll('.geojson-map, .topojson-map'); + mapNodes.forEach(node => { + const map = node._leafletMap; + if (map) { + map.eachLayer(layer => { + if (layer instanceof L.TileLayer) { + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + let tileUrl; + let tileAttribution = '© OpenStreetMap contributors'; + if (currentTheme === 'dark') { + tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } else { + tileUrl = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'; + tileAttribution += ' © CARTO'; + } + layer.setUrl(tileUrl); + layer.setAttribution(tileAttribution); + } + }); + } + }); + } + + function updateStlThemes() { + if (typeof THREE === 'undefined') return; + const stlNodes = markdownPreview.querySelectorAll('.stl-viewer'); + stlNodes.forEach(node => { + const view = activeStlViews.get(node.id); + if (view && view.scene) { + const currentTheme = document.documentElement.getAttribute("data-theme") || 'light'; + const matColor = currentTheme === 'dark' ? 0x90caf9 : 0x1976d2; + + view.scene.traverse(child => { + if (child instanceof THREE.Mesh && child.material) { + child.material.color.setHex(matColor); + child.material.needsUpdate = true; + } + }); + } + }); + } + function postProcessPreview(rawVal, context, patchResult) { const roots = getPreviewPostProcessRoots(patchResult); + // Clean up orphaned STL views that are no longer present in the document + activeStlViews.forEach((view, id) => { + if (!document.body.contains(view.container)) { + disposeStlView(id); + } + }); + roots.forEach(function(root) { processEmojis(root); }); @@ -2317,6 +2642,99 @@ document.addEventListener("DOMContentLoaded", function () { console.warn("ABC notation processing failed:", e); } + try { + const geojsonNodes = queryPreviewRoots(roots, '.geojson-map'); + const topojsonNodes = queryPreviewRoots(roots, '.topojson-map'); + + if (geojsonNodes.length > 0 || topojsonNodes.length > 0) { + const renderAllMaps = function() { + if (context.renderId !== previewRenderGeneration) return; + geojsonNodes.forEach(node => renderMapNode(node, false, context)); + topojsonNodes.forEach(node => renderMapNode(node, true, context)); + }; + + const promises = []; + if (typeof L === 'undefined') { + promises.push(loadStyle(CDN.leaflet_css)); + promises.push(loadScript(CDN.leaflet_js)); + } + if (topojsonNodes.length > 0 && typeof topojson === 'undefined') { + promises.push(loadScript(CDN.topojson)); + } + + if (promises.length > 0) { + Promise.all(promises).then(function() { + renderAllMaps(); + }).catch(function(e) { + console.warn('Failed to load map libraries:', e); + geojsonNodes.concat(topojsonNodes).forEach(node => { + const container = node.closest('.geojson-container') || node.closest('.topojson-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } else { + renderAllMaps(); + } + } + } catch (e) { + console.warn("GeoJSON/TopoJSON processing failed:", e); + } + + try { + const stlNodes = queryPreviewRoots(roots, '.stl-viewer'); + if (stlNodes.length > 0) { + const renderAllStls = function() { + if (context.renderId !== previewRenderGeneration) return; + stlNodes.forEach(node => renderStlNode(node, context)); + }; + + const promises = []; + if (typeof THREE === 'undefined') { + promises.push(loadScript(CDN.three)); + } + + const loadLoaderAndControls = function() { + const subPromises = []; + if (typeof THREE.STLLoader === 'undefined') { + subPromises.push(loadScript(CDN.stlLoader)); + } + if (typeof THREE.OrbitControls === 'undefined') { + subPromises.push(loadScript(CDN.orbitControls)); + } + if (subPromises.length > 0) { + return Promise.all(subPromises); + } + return Promise.resolve(); + }; + + if (typeof THREE === 'undefined') { + loadScript(CDN.three).then(function() { + return loadLoaderAndControls(); + }).then(function() { + renderAllStls(); + }).catch(function(e) { + console.warn('Failed to load Three.js libraries:', e); + stlNodes.forEach(node => { + const container = node.closest('.stl-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } else { + loadLoaderAndControls().then(function() { + renderAllStls(); + }).catch(function(e) { + console.warn('Failed to load Three.js addons:', e); + stlNodes.forEach(node => { + const container = node.closest('.stl-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } + } + } catch (e) { + console.warn("STL processing failed:", e); + } + const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '') || /```math\b/.test(rawVal || ''); if (hasMath) { const typesetTargets = roots.filter(function(root) { @@ -6737,6 +7155,9 @@ document.addEventListener("DOMContentLoaded", function () { console.warn('Mermaid theme re-render failed:', e); } } + + updateMapThemes(); + updateStlThemes(); }); async function nativeSaveMarkdown() { diff --git a/styles.css b/styles.css index a6191ae4..7d14faef 100644 --- a/styles.css +++ b/styles.css @@ -3943,6 +3943,167 @@ html[lang="ko"] .markdown-body h1, html[lang="ko"] .markdown-body h2, html[lang= border: 0; } +/* --- GeoJSON and TopoJSON Map Styles --- */ +.geojson-container, .topojson-container { + display: flex; + flex-direction: column; + margin: 1.5em 0; + padding: 0.5em; + background-color: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.geojson-container.is-loading, .topojson-container.is-loading { + min-height: 400px; + background-color: var(--skeleton-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; + animation: skeleton-pulse 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; +} + +.geojson-container.is-loading .geojson-map, +.topojson-container.is-loading .topojson-map { + opacity: 0; +} + +.geojson-container.is-loading::after, +.topojson-container.is-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + var(--skeleton-glow) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: skeleton-shimmer 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.geojson-map, .topojson-map { + width: 100%; + height: 400px; + border-radius: 4px; + background: transparent; +} + +/* Map Popup Styling */ +.map-popup-container { + max-width: 300px; + max-height: 200px; + overflow-y: auto; + font-family: var(--font-sans); + font-size: 13px; + color: var(--text-color, #333); +} + +.leaflet-popup-content-wrapper { + background-color: var(--code-bg) !important; + color: var(--text-color) !important; + border: 1px solid var(--border-color) !important; + border-radius: 6px !important; +} + +.leaflet-popup-tip { + background-color: var(--code-bg) !important; + border: 1px solid var(--border-color) !important; +} + +.map-popup-table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.map-popup-table td { + padding: 4px 8px; + border-bottom: 1px solid var(--border-color); + vertical-align: top; + word-break: break-word; +} + +.map-popup-table tr:last-child td { + border-bottom: none; +} + +.map-popup-table td.prop-key { + color: var(--accent-color, #0969da); + font-weight: 600; + padding-right: 12px; +} + +.map-popup-table td.prop-val { + color: var(--text-color); +} + +/* --- STL 3D Viewer Styles --- */ +.stl-container { + display: flex; + flex-direction: column; + margin: 1.5em 0; + padding: 0.5em; + background-color: var(--code-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + position: relative; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.stl-container.is-loading { + min-height: 400px; + background-color: var(--skeleton-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + position: relative; + overflow: hidden; + animation: skeleton-pulse 2.2s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; +} + +.stl-container.is-loading .stl-viewer { + opacity: 0; +} + +.stl-container.is-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + var(--skeleton-glow) 50%, + rgba(255, 255, 255, 0) 100% + ); + animation: skeleton-shimmer 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.stl-viewer { + width: 100%; + height: 400px; + border-radius: 4px; + outline: none; +} + +.stl-viewer canvas { + display: block; +} + +.stl-toolbar { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 4px 8px; + background-color: var(--editor-bg); + border-bottom: 1px solid var(--border-color); +} + /* Accessibility: respect user's motion preferences */ @media (prefers-reduced-motion: reduce) { .skeleton-placeholder, @@ -3950,7 +4111,13 @@ html[lang="ko"] .markdown-body h1, html[lang="ko"] .markdown-body h2, html[lang= .mermaid-container.is-loading, .mermaid-container.is-loading::after, .abc-container.is-loading, - .abc-container.is-loading::after { + .abc-container.is-loading::after, + .geojson-container.is-loading, + .geojson-container.is-loading::after, + .topojson-container.is-loading, + .topojson-container.is-loading::after, + .stl-container.is-loading, + .stl-container.is-loading::after { animation: none; } .drag-overlay-inner { From b3800fe66e46ffb5b09cca5a30d290f5cc46ce51 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Thu, 18 Jun 2026 15:22:08 +0530 Subject: [PATCH 2/8] feat: Add advanced controls, coordinate grid, surface angle shading, and modal zoom to 3D STL viewer --- desktop-app/resources/index.html | 31 ++ desktop-app/resources/js/script.js | 469 +++++++++++++++++++++++------ desktop-app/resources/styles.css | 159 ++++++++++ index.html | 31 ++ script.js | 469 +++++++++++++++++++++++------ styles.css | 159 ++++++++++ 6 files changed, 1146 insertions(+), 172 deletions(-) diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index bf8e390d..d4985b42 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -878,6 +878,37 @@ + + +