diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index ba40c3cc..44b612b8 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -59,23 +59,24 @@ console.log("✓ Copied assets/ → resources/assets/ (excluding GIF demos)"); /** * Validates the cryptographic integrity of a file against an expected SHA-384 hash. */ -function verifyIntegrity(filePath, expectedSha384) { +function verifyIntegrity(filePath, expectedHash) { return new Promise((resolve, reject) => { - if (!expectedSha384) { + if (!expectedHash) { resolve(true); // Skip validation if no hash is provided (e.g., relative fonts) return; } - const hash = crypto.createHash("sha384"); + const algo = expectedHash.startsWith("sha512-") ? "sha512" : "sha384"; + const hash = crypto.createHash(algo); const stream = fs.createReadStream(filePath); stream.on("data", data => hash.update(data)); stream.on("end", () => { - const calculated = "sha384-" + hash.digest("base64"); - if (calculated === expectedSha384) { + const calculated = algo + "-" + hash.digest("base64"); + if (calculated === expectedHash) { resolve(true); } else { - reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedSha384}\nCalculated: ${calculated}`)); + reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedHash}\nCalculated: ${calculated}`)); } }); stream.on("error", reject); @@ -187,6 +188,19 @@ 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)); + // 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==" + } + ]; + + for (const lib of dynamicLibs) { + downloads.push(downloadFile(lib.url, lib.dest, lib.hash)); + } + // Wait for all downloads and cryptographic validations to finish try { await Promise.all(downloads); diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index 97c9a9a2..ba54be73 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -3,6 +3,7 @@ let librariesLoaded = false; let markedConfigured = false; let mermaidIdCounter = 0; +let abcIdCounter = 0; const markedOptions = { gfm: true, @@ -312,6 +313,11 @@ function configureMarked() { return `
${escapeHtml(code)}
`; } + if (language === "abc") { + const uniqueId = `abc-notation-worker-${abcIdCounter++}`; + return `
${escapeHtml(code)}
`; + } + const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext"; const highlightedCode = hljs ? hljs.highlight(code, { language: validLanguage }).value @@ -481,6 +487,7 @@ self.onmessage = function(event) { const options = data.options || {}; ensureLibraries(options.libraryUrls || {}); mermaidIdCounter = 0; + abcIdCounter = 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 9f457d2a..a0652a7a 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -33,9 +33,15 @@ document.addEventListener("DOMContentLoaded", function () { html2canvas: 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js', 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' + 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' }; + // Resolve local paths for desktop (Neutralinojs) offline support + if (typeof Neutralino !== 'undefined') { + CDN.abcjs = '/libs/abcjs-basic-min.js'; + } + let markdownRenderTimeout = null; let pendingPreviewRenderCancel = null; let previewRenderGeneration = 0; @@ -52,7 +58,7 @@ document.addEventListener("DOMContentLoaded", function () { const PREVIEW_BLOCK_REUSE_LIMIT = 12000; const PREVIEW_SANITIZE_OPTIONS = { ADD_TAGS: ['mjx-container', 'input'], - ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code'], + ADD_ATTR: ['id', 'class', 'style', 'align', 'type', 'checked', 'disabled', 'data-original-code', 'role', 'aria-labelledby', 'aria-describedby'], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }; const RENDER_DELAY = 100; @@ -924,6 +930,15 @@ document.addEventListener("DOMContentLoaded", function () { .replace(/>/g, ">"); return `
${escapedCode}
`; } + + if (language === 'abc') { + const uniqueId = 'abc-notation-' + Math.random().toString(36).substr(2, 9); + const escapedCode = code + .replace(/&/g, "&") + .replace(//g, ">"); + return `
${escapedCode}
`; + } const validLanguage = hljs.getLanguage(language) ? language : "plaintext"; const highlightedCode = hljs.highlight(code, { @@ -2094,6 +2109,23 @@ document.addEventListener("DOMContentLoaded", function () { queryPreviewRoots(roots, '.mermaid-container.is-loading').forEach(function(container) { container.classList.remove('is-loading'); }); + queryPreviewRoots(roots, '.abc-container.is-loading').forEach(function(container) { + container.classList.remove('is-loading'); + }); + } + + function parseAbcHeaders(abcString) { + const titleMatch = /^T:\s*(.*)$/m.exec(abcString); + const composerMatch = /^C:\s*(.*)$/m.exec(abcString); + const keyMatch = /^K:\s*(.*)$/m.exec(abcString); + const meterMatch = /^M:\s*(.*)$/m.exec(abcString); + + return { + title: titleMatch ? titleMatch[1].trim() : "Music notation block", + composer: composerMatch ? composerMatch[1].trim() : "Traditional", + key: keyMatch ? keyMatch[1].trim() : "C", + meter: meterMatch ? meterMatch[1].trim() : "4/4" + }; } function postProcessPreview(rawVal, context, patchResult) { @@ -2143,6 +2175,131 @@ document.addEventListener("DOMContentLoaded", function () { console.warn("Mermaid rendering failed:", e); } + try { + const abcNodes = queryPreviewRoots(roots, '.abc-notation'); + if (abcNodes.length > 0) { + const renderAbcNodes = function() { + if (context.renderId !== previewRenderGeneration) return; + + const observer = new IntersectionObserver((entries, obs) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const node = entry.target; + obs.unobserve(node); + + setTimeout(() => { + if (context.renderId !== previewRenderGeneration) return; + const originalCode = node.getAttribute('data-original-code'); + if (!originalCode) return; + const decodedCode = decodeURIComponent(originalCode); + + const container = node.closest('.abc-container'); + try { + node.innerHTML = ''; + ABCJS.renderAbc(node.id, decodedCode, { + responsive: "resize", + add_classes: true + }); + + node.innerHTML = DOMPurify.sanitize(node.innerHTML, PREVIEW_SANITIZE_OPTIONS); + + const headers = parseAbcHeaders(decodedCode); + const svgElement = node.querySelector('svg'); + if (svgElement) { + svgElement.setAttribute('role', 'img'); + const titleId = 'abc-title-' + node.id; + const descId = 'abc-desc-' + node.id; + svgElement.setAttribute('aria-labelledby', titleId + ' ' + descId); + svgElement.setAttribute('aria-describedby', 'abc-source-' + node.id); + + const svgTitle = document.createElementNS('http://www.w3.org/2000/svg', 'title'); + svgTitle.id = titleId; + svgTitle.textContent = `Sheet music for: ${headers.title}`; + + const svgDesc = document.createElementNS('http://www.w3.org/2000/svg', 'desc'); + svgDesc.id = descId; + svgDesc.textContent = `Score in ${headers.key}, ${headers.meter} meter, composed by ${headers.composer}.`; + + svgElement.insertBefore(svgDesc, svgElement.firstChild); + svgElement.insertBefore(svgTitle, svgElement.firstChild); + } + + if (container) { + container.classList.remove('is-loading'); + + if (!container.querySelector('.abc-toolbar')) { + const toolbar = document.createElement('div'); + toolbar.className = 'abc-toolbar'; + toolbar.style.cssText = 'display: flex; justify-content: flex-end; width: 100%; margin-bottom: 0.5em;'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'tool-button abc-toggle-btn'; + btn.setAttribute('aria-pressed', 'false'); + btn.style.cssText = 'padding: 2px 8px; font-size: 11px;'; + btn.innerHTML = 'View Code'; + toolbar.appendChild(btn); + + const rawPre = document.createElement('pre'); + rawPre.className = 'abc-raw-code'; + rawPre.style.cssText = 'display: none; width: 100%; margin: 0; padding: 1em; background: var(--editor-bg); border-radius: 4px; overflow-x: auto; font-family: var(--font-mono); font-size: 12px; color: var(--text-color);'; + rawPre.textContent = decodedCode; + + const srOnlyDiv = document.createElement('div'); + srOnlyDiv.className = 'abc-sr-only'; + srOnlyDiv.id = 'abc-source-' + node.id; + srOnlyDiv.textContent = decodedCode; + + container.insertBefore(toolbar, node); + container.appendChild(rawPre); + container.appendChild(srOnlyDiv); + + btn.addEventListener('click', function() { + const isPressed = btn.getAttribute('aria-pressed') === 'true'; + btn.setAttribute('aria-pressed', !isPressed); + if (!isPressed) { + node.style.display = 'none'; + rawPre.style.display = 'block'; + btn.innerHTML = 'View Score'; + } else { + node.style.display = 'block'; + rawPre.style.display = 'none'; + btn.innerHTML = 'View Code'; + } + }); + } + } + } catch (err) { + console.error("ABCJS rendering failed:", err); + if (container) container.classList.remove('is-loading'); + } + }, 0); + } + }); + }, { rootMargin: '150px 0px' }); + + abcNodes.forEach(node => observer.observe(node)); + }; + + if (typeof ABCJS === 'undefined') { + loadScript(CDN.abcjs).then(function() { + if (context.renderId !== previewRenderGeneration) return; + renderAbcNodes(); + }).catch(function(e) { + console.warn('Failed to load abcjs:', e); + abcNodes.forEach(function(node) { + const container = node.closest('.abc-container'); + if (container) container.classList.remove('is-loading'); + }); + }); + } else { + renderAbcNodes(); + } + } + } catch (e) { + console.warn("ABC notation processing failed:", e); + } + const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || ''); if (hasMath) { const typesetTargets = roots.filter(function(root) { @@ -6739,6 +6896,7 @@ document.addEventListener("DOMContentLoaded", function () { +