diff --git a/desktop-app/prepare.js b/desktop-app/prepare.js index 7fa6ee2..ba40c3c 100644 --- a/desktop-app/prepare.js +++ b/desktop-app/prepare.js @@ -46,6 +46,9 @@ function copyDirSync(src, dest, excludePatterns) { fs.copyFileSync(path.join(ROOT_DIR, "script.js"), path.join(jsDest, "script.js")); console.log("✓ Copied script.js → resources/js/script.js"); +fs.copyFileSync(path.join(ROOT_DIR, "preview-worker.js"), path.join(jsDest, "preview-worker.js")); +console.log("Copied preview-worker.js to resources/js/preview-worker.js"); + fs.copyFileSync(path.join(ROOT_DIR, "styles.css"), path.join(RESOURCES_DIR, "styles.css")); console.log("✓ Copied styles.css → resources/styles.css"); diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js new file mode 100644 index 0000000..ca19372 --- /dev/null +++ b/desktop-app/resources/js/preview-worker.js @@ -0,0 +1,483 @@ +/* global importScripts, marked, hljs */ + +let librariesLoaded = false; +let markedConfigured = false; +let mermaidIdCounter = 0; + +const markedOptions = { + gfm: true, + breaks: true, + pedantic: false, + sanitize: false, + smartypants: false, + xhtml: false, + headerIds: true, + mangle: false, +}; + +const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; +const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; +const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; +const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; +const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; +const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; +const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; +const EMPTY_LINE_PATTERN = /^\s*$/; + +let suppressFootnotePreprocess = false; +const footnoteDefinitions = new Map(); +const footnoteOrder = []; +const footnoteRefCounts = new Map(); +const footnoteFirstRefId = new Map(); +let anonymousFootnoteCounter = 0; + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function escapeHtmlAttribute(value) { + return String(value) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +function resetExtendedMarkdownState() { + footnoteDefinitions.clear(); + footnoteOrder.length = 0; + footnoteRefCounts.clear(); + footnoteFirstRefId.clear(); + anonymousFootnoteCounter = 0; +} + +function normalizeFootnoteId(id) { + const normalized = String(id || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (normalized) return normalized; + anonymousFootnoteCounter += 1; + return `footnote-${anonymousFootnoteCounter}`; +} + +function parseInlineWithoutFootnotes(text) { + suppressFootnotePreprocess = true; + try { + return marked.parseInline(text); + } finally { + suppressFootnotePreprocess = false; + } +} + +function renderDefinitionContent(content, options) { + const appendHtml = options && options.appendHtml ? options.appendHtml : ""; + const paragraphs = String(content || "") + .split(/\n(?:[ \t]*\n)+/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + + if (appendHtml) { + if (paragraphs.length === 0) { + paragraphs.push(appendHtml); + } else { + paragraphs[paragraphs.length - 1] = `${paragraphs[paragraphs.length - 1]} ${appendHtml}`; + } + } + + return paragraphs + .map((paragraph) => `

${parseInlineWithoutFootnotes(paragraph)}

`) + .join(""); +} + +function extractFootnoteDefinitions(markdown) { + const lines = markdown.split("\n"); + const preservedLines = []; + let index = 0; + + while (index < lines.length) { + const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]); + if (!match) { + preservedLines.push(lines[index]); + index += 1; + continue; + } + + const baseIndent = match[1] || ""; + const id = match[2].trim(); + const definitionLines = [match[3] || ""]; + index += 1; + + while (index < lines.length) { + const line = lines[index]; + if (!line.startsWith(baseIndent)) break; + const lineAfterBase = line.slice(baseIndent.length); + const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); + if (indentedMatch) { + definitionLines.push(indentedMatch[1]); + index += 1; + continue; + } + if (lineAfterBase.trim() === "") { + const nextLine = lines[index + 1] || ""; + const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : ""; + if (/^(?: {2,}|\t)/.test(nextAfterBase)) { + definitionLines.push(""); + index += 1; + continue; + } + } + break; + } + + footnoteDefinitions.set(id, definitionLines.join("\n").trim()); + } + + return preservedLines.join("\n"); +} + +function applyFootnotes(markdown) { + const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) { + const id = idText.trim(); + if (!id) return match; + if (!footnoteOrder.includes(id)) footnoteOrder.push(id); + + const refCount = (footnoteRefCounts.get(id) || 0) + 1; + footnoteRefCounts.set(id, refCount); + + const normalizedId = normalizeFootnoteId(id); + const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; + if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + + const noteNumber = footnoteOrder.indexOf(id) + 1; + return `[${noteNumber}]`; + }); + + const footnotesHtml = footnoteOrder + .filter((id) => footnoteDefinitions.has(id)) + .map((id) => { + const normalizedId = normalizeFootnoteId(id); + const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; + const backRefHtml = ``; + const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml }); + return `
  • ${noteHtml}
  • `; + }) + .join(""); + + if (!footnotesHtml) return markdownWithReferences; + return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; +} + +function configureMarked() { + if (markedConfigured) return; + + const renderer = new marked.Renderer(); + const blockMathExtension = { + name: "blockMath", + level: "block", + start(src) { + const match = src.match(BLOCK_MATH_MARKER_PATTERN); + return match ? match.index : undefined; + }, + tokenizer(src) { + const match = BLOCK_MATH_PATTERN.exec(src); + if (!match) return undefined; + return { type: "blockMath", raw: match[0], text: match[1] }; + }, + renderer(token) { + return `
    $$\n${token.text}\n$$
    \n`; + }, + }; + + const definitionListExtension = { + name: "definitionList", + level: "block", + start(src) { + const match = src.match(/\n:[ \t]+/); + return match ? match.index + 1 : undefined; + }, + tokenizer(src) { + const lines = src.split("\n"); + if (lines.length < 2) return undefined; + + const term = lines[0]; + if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) return undefined; + if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) return undefined; + + const definitions = []; + const rawLines = [term]; + let index = 1; + while (index < lines.length) { + const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); + if (!itemMatch) break; + + rawLines.push(lines[index]); + const definitionLines = [itemMatch[1]]; + index += 1; + + while (index < lines.length) { + const line = lines[index]; + if (DEFINITION_LIST_ITEM_PATTERN.test(line)) break; + if (EMPTY_LINE_PATTERN.test(line)) { + const nextLine = lines[index + 1] || ""; + if (/^(?: {2,}|\t)/.test(nextLine)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + break; + } + const continuationMatch = /^(?: {2,}|\t)(.*)$/.exec(line); + if (!continuationMatch) break; + rawLines.push(line); + definitionLines.push(continuationMatch[1]); + index += 1; + } + + definitions.push(definitionLines.join("\n").trim()); + } + + if (definitions.length === 0) return undefined; + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) raw += "\n"; + return { type: "definitionList", raw, term: term.trim(), definitions }; + }, + renderer(token) { + const termHtml = parseInlineWithoutFootnotes(token.term); + const definitionHtml = token.definitions + .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) + .join(""); + return `
    ${termHtml}
    ${definitionHtml}
    \n`; + }, + }; + + const superscriptExtension = { + name: "superscript", + level: "inline", + start(src) { + const index = src.indexOf("^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUPERSCRIPT_PATTERN.exec(src); + return match ? { type: "superscript", raw: match[0], text: match[1] } : undefined; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + + const subscriptExtension = { + name: "subscript", + level: "inline", + start(src) { + const index = src.indexOf("~"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = SUBSCRIPT_PATTERN.exec(src); + return match ? { type: "subscript", raw: match[0], text: match[1] } : undefined; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + + const highlightExtension = { + name: "highlight", + level: "inline", + start(src) { + const index = src.indexOf("=="); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + const match = HIGHLIGHT_PATTERN.exec(src); + return match ? { type: "highlight", raw: match[0], text: match[1] } : undefined; + }, + renderer(token) { + return `${marked.parseInline(token.text)}`; + }, + }; + + renderer.code = function(code, language) { + if (language === "mermaid") { + const uniqueId = `mermaid-diagram-worker-${mermaidIdCounter++}`; + return `
    ${escapeHtml(code)}
    `; + } + + const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext"; + const highlightedCode = hljs + ? hljs.highlight(code, { language: validLanguage }).value + : escapeHtml(code); + return `
    ${highlightedCode}
    `; + }; + + marked.use({ + extensions: [ + blockMathExtension, + definitionListExtension, + superscriptExtension, + subscriptExtension, + highlightExtension, + ], + hooks: { + preprocess(markdown) { + if (suppressFootnotePreprocess) return markdown; + resetExtendedMarkdownState(); + const protectedMarkdown = markdown.replace(/\\\$/g, "$"); + return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); + }, + }, + }); + + marked.setOptions(Object.assign({}, markedOptions, { renderer })); + markedConfigured = true; +} + +function ensureLibraries(urls) { + if (!librariesLoaded) { + importScripts(urls.marked, urls.highlight); + librariesLoaded = true; + } + configureMarked(); +} + +function isSegmentedPreviewSafe(markdown) { + if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; + if (/\[\^[^\]\n]+\]/.test(markdown)) return false; + if (/\n:[ \t]+/.test(markdown)) return false; + if (/^\s{0,3}<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>)/m.test(markdown)) return false; + return true; +} + +function hashString(value) { + let hash = 2166136261; + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function splitMarkdownBlocks(markdown) { + const normalized = String(markdown || "").replace(/\r\n/g, "\n"); + const lines = normalized.split("\n"); + const blocks = []; + let buffer = []; + let startLine = 1; + let inFence = false; + let fenceChar = ""; + let fenceLength = 0; + let inMathBlock = false; + + function flush(endLine) { + const source = buffer.join("\n").trimEnd(); + if (source.trim()) { + blocks.push({ + source, + startLine, + endLine, + }); + } + buffer = []; + } + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const lineNumber = index + 1; + const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line); + const trimmed = line.trim(); + + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!inFence) { + inFence = true; + fenceChar = marker[0]; + fenceLength = marker.length; + } else if (marker[0] === fenceChar && marker.length >= fenceLength) { + inFence = false; + } + } + + if (!inFence && trimmed === "$$") { + inMathBlock = !inMathBlock; + } + + if (!inFence && !inMathBlock && trimmed === "") { + flush(lineNumber); + startLine = lineNumber + 1; + continue; + } + + if (buffer.length === 0) startLine = lineNumber; + buffer.push(line); + } + + flush(lines.length); + return blocks; +} + +function renderSegmentedMarkdown(markdown, options) { + if (!isSegmentedPreviewSafe(markdown)) { + return { mode: "full-required", reason: "unsafe-markdown" }; + } + + const blocks = splitMarkdownBlocks(markdown); + if (blocks.length < (options.minimumBlocks || 1)) { + return { mode: "full-required", reason: "too-few-blocks" }; + } + + const seenHashes = new Map(); + const renderedBlocks = blocks.map((block) => { + const hash = hashString(block.source); + const seenCount = seenHashes.get(hash) || 0; + seenHashes.set(hash, seenCount + 1); + const html = marked.parse(block.source); + return { + id: `preview-block-${hash}-${seenCount}`, + hash, + html, + htmlLength: html.length, + sourceLength: block.source.length, + startLine: block.startLine, + endLine: block.endLine, + }; + }); + + return { + mode: "segmented", + blocks: renderedBlocks, + blockCount: renderedBlocks.length, + }; +} + +self.onmessage = function(event) { + const data = event.data || {}; + if (data.type !== "render") return; + + try { + const options = data.options || {}; + ensureLibraries(options.libraryUrls || {}); + mermaidIdCounter = 0; + const result = renderSegmentedMarkdown(data.markdown || "", options); + self.postMessage({ + type: "render-result", + requestId: data.requestId, + result, + }); + } catch (error) { + self.postMessage({ + type: "render-error", + requestId: data.requestId, + error: error && error.message ? error.message : "Preview worker render failed.", + }); + } +}; diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 42e56f4..05662fb 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -37,9 +37,27 @@ document.addEventListener("DOMContentLoaded", function () { }; let markdownRenderTimeout = null; + let pendingPreviewRenderCancel = null; + let previewRenderGeneration = 0; + let previewHasCommittedRender = false; + let previewLastRenderedTabId = null; // PERF-003: Track last rendered content to skip redundant renders let _lastRenderedContent = null; + const LARGE_DOCUMENT_THRESHOLD = 15000; + const HUGE_DOCUMENT_THRESHOLD = 100000; + const PREVIEW_ENGINE_V2_ENABLED = true; + const PREVIEW_WORKER_THRESHOLD = 50000; + const PREVIEW_WORKER_TIMEOUT = 12000; + const PREVIEW_SEGMENT_MIN_BLOCKS = 8; + 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'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i + }; const RENDER_DELAY = 100; + const LARGE_RENDER_DELAY = 160; + const HUGE_RENDER_DELAY = 240; let syncScrollingEnabled = true; let isEditorScrolling = false; let isPreviewScrolling = false; @@ -48,7 +66,7 @@ document.addEventListener("DOMContentLoaded", function () { // View Mode State - Story 1.1 let currentViewMode = 'split'; // 'editor', 'split', or 'preview' - const APP_VERSION = '3.7.2'; + const APP_VERSION = '3.7.6'; let activeModal = null; let lastFocusedElement = null; let isFindModalOpen = false; @@ -65,6 +83,13 @@ document.addEventListener("DOMContentLoaded", function () { let lastCursorStart = 0; let lastCursorEnd = 0; let pendingState = null; + let previewWorker = null; + let previewWorkerUnavailable = false; + let previewWorkerRequestCounter = 0; + let previewWorkerFailureCount = 0; + const previewWorkerRequests = new Map(); + const previewSegmentHtmlCache = new Map(); + let previewSegmentCacheTabId = null; const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); @@ -321,8 +346,16 @@ document.addEventListener("DOMContentLoaded", function () { const LINE_NUMBER_GUTTER_MIN_CH = 3; const LINE_NUMBER_GUTTER_PADDING_CH = 1; const LINE_NUMBER_EMPTY_PLACEHOLDER = '\u200b'; + const LINE_CACHE_MAX_ENTRIES = 5000; + const LARGE_EDITOR_WORK_DELAY = 180; + const HUGE_EDITOR_WORK_DELAY = 320; + const FIND_REFRESH_DELAY = 120; + const LARGE_FIND_REFRESH_DELAY = 320; let lineNumberMeasure = null; let lineNumberUpdateFrame = null; + let lineNumberUpdateTimeout = null; + let editorOverlayScrollFrame = null; + let findRefreshTimeout = null; const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; @@ -371,6 +404,196 @@ document.addEventListener("DOMContentLoaded", function () { .replace(/>/g, ">"); } + function sanitizePreviewHtml(html) { + if (typeof DOMPurify === "undefined") { + throw new ReferenceError("DOMPurify is not defined. Secure rendering aborted."); + } + return DOMPurify.sanitize(html, PREVIEW_SANITIZE_OPTIONS); + } + + function getLoadedScriptUrl(needle, fallbackUrl) { + const scripts = document.getElementsByTagName("script"); + for (let i = 0; i < scripts.length; i += 1) { + const src = scripts[i].getAttribute("src") || ""; + if (src.includes(needle)) { + try { + return new URL(src, window.location.href).toString(); + } catch (e) { + return src; + } + } + } + return fallbackUrl; + } + + function getPreviewWorkerUrl() { + const scripts = document.getElementsByTagName("script"); + let scriptUrl = ""; + for (let i = scripts.length - 1; i >= 0; i -= 1) { + const src = scripts[i].getAttribute("src") || ""; + if (src.includes("script.js")) { + scriptUrl = src; + break; + } + } + + try { + return new URL("preview-worker.js", scriptUrl ? new URL(scriptUrl, window.location.href) : window.location.href).toString(); + } catch (e) { + return "preview-worker.js"; + } + } + + function getPreviewWorkerLibraryUrls() { + return { + marked: getLoadedScriptUrl("marked", "https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"), + highlight: getLoadedScriptUrl("highlight", "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"), + }; + } + + function isSegmentedPreviewSafe(markdown) { + if (!markdown || markdown.length < PREVIEW_WORKER_THRESHOLD) return false; + if (/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(markdown)) return false; + if (/^\[[^\]\n]+\]:\s+\S+/m.test(markdown)) return false; + if (/\[\^[^\]\n]+\]/.test(markdown)) return false; + if (/\n:[ \t]+/.test(markdown)) return false; + if (/^\s{0,3}<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>)/m.test(markdown)) return false; + return true; + } + + function shouldUsePreviewWorker(rawVal, context) { + if (!PREVIEW_ENGINE_V2_ENABLED || previewWorkerUnavailable || context.disableWorker) return false; + if (typeof Worker === "undefined" || typeof URL === "undefined") return false; + return isSegmentedPreviewSafe(rawVal); + } + + function resetPreviewSegmentCache(previewDocumentId) { + if (previewSegmentCacheTabId !== previewDocumentId) { + previewSegmentHtmlCache.clear(); + previewSegmentCacheTabId = previewDocumentId; + } + } + + function trimPreviewSegmentCache() { + while (previewSegmentHtmlCache.size > PREVIEW_BLOCK_REUSE_LIMIT) { + const firstKey = previewSegmentHtmlCache.keys().next().value; + previewSegmentHtmlCache.delete(firstKey); + } + } + + function buildSegmentedPreviewHtml(blocks, previewDocumentId) { + resetPreviewSegmentCache(previewDocumentId); + const htmlParts = []; + + blocks.forEach(function(block, index) { + const hash = String(block.hash || ""); + const cacheKey = `${hash}:${block.sourceLength || 0}:${block.htmlLength || (block.html ? block.html.length : 0)}`; + let sanitizedBlock = previewSegmentHtmlCache.get(cacheKey); + if (sanitizedBlock === undefined) { + sanitizedBlock = sanitizePreviewHtml(block.html || ""); + previewSegmentHtmlCache.set(cacheKey, sanitizedBlock); + } + const blockId = block.id || `preview-block-${index}`; + htmlParts.push( + `
    ${sanitizedBlock}
    ` + ); + }); + + trimPreviewSegmentCache(); + return htmlParts.join(""); + } + + function markPreviewWorkerFailure(error) { + previewWorkerFailureCount += 1; + if (previewWorkerFailureCount >= 2) { + previewWorkerUnavailable = true; + } + if (previewWorker) { + try { + previewWorker.terminate(); + } catch (e) { + // Ignore worker shutdown failures; fallback rendering will continue on main. + } + previewWorker = null; + } + previewWorkerRequests.forEach(function(pending) { + clearTimeout(pending.timeoutId); + pending.reject(error || new Error("Preview worker unavailable.")); + }); + previewWorkerRequests.clear(); + } + + function recordPreviewWorkerRenderFailure() { + previewWorkerFailureCount += 1; + if (previewWorkerFailureCount < 2) return; + previewWorkerUnavailable = true; + if (previewWorker) { + try { + previewWorker.terminate(); + } catch (e) { + // Ignore worker shutdown failures; fallback rendering will continue on main. + } + previewWorker = null; + } + } + + function getPreviewWorker() { + if (previewWorkerUnavailable) return null; + if (previewWorker) return previewWorker; + try { + previewWorker = new Worker(getPreviewWorkerUrl()); + previewWorker.onmessage = function(event) { + const data = event.data || {}; + const pending = previewWorkerRequests.get(data.requestId); + if (!pending) return; + clearTimeout(pending.timeoutId); + previewWorkerRequests.delete(data.requestId); + if (data.type === "render-result") { + previewWorkerFailureCount = 0; + pending.resolve(data.result); + } else { + recordPreviewWorkerRenderFailure(); + pending.reject(new Error(data.error || "Preview worker render failed.")); + } + }; + previewWorker.onerror = function(event) { + markPreviewWorkerFailure(event && event.message ? new Error(event.message) : new Error("Preview worker failed.")); + }; + } catch (e) { + markPreviewWorkerFailure(e); + return null; + } + return previewWorker; + } + + function requestPreviewWorkerRender(rawVal, context) { + const worker = getPreviewWorker(); + if (!worker) { + return Promise.reject(new Error("Preview worker unavailable.")); + } + + const requestId = ++previewWorkerRequestCounter; + return new Promise(function(resolve, reject) { + const timeoutId = setTimeout(function() { + previewWorkerRequests.delete(requestId); + recordPreviewWorkerRenderFailure(); + reject(new Error("Preview worker timed out.")); + }, PREVIEW_WORKER_TIMEOUT); + + previewWorkerRequests.set(requestId, { resolve, reject, timeoutId }); + worker.postMessage({ + type: "render", + requestId, + markdown: rawVal, + options: { + minimumBlocks: PREVIEW_SEGMENT_MIN_BLOCKS, + libraryUrls: getPreviewWorkerLibraryUrls(), + renderId: context.renderId, + }, + }); + }); + } + function parseInlineWithoutFootnotes(text) { suppressFootnotePreprocess = true; try { @@ -1601,6 +1824,8 @@ document.addEventListener("DOMContentLoaded", function () { function showPreviewSkeleton() { if (markdownPreview && !markdownPreview.querySelector('#markdown-preview-skeleton')) { + markdownPreview.setAttribute('aria-busy', 'true'); + markdownPreview.dataset.renderState = 'loading'; markdownPreview.innerHTML = `