document.addEventListener("DOMContentLoaded", function () { // PERF-002: Lazy script loader for optional heavy libraries const _loadedScripts = new Set(); function loadScript(url) { if (_loadedScripts.has(url)) return Promise.resolve(); return new Promise(function(resolve, reject) { const script = document.createElement('script'); script.src = url; script.onload = function() { _loadedScripts.add(url); resolve(); }; script.onerror = function() { reject(new Error('Failed to load: ' + url)); }; document.head.appendChild(script); }); } const _loadedStyles = new Set(); function loadStyle(url) { if (_loadedStyles.has(url)) return Promise.resolve(); return new Promise(function(resolve, reject) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = url; link.onload = function() { _loadedStyles.add(url); resolve(); }; link.onerror = function() { reject(new Error('Failed to load style: ' + url)); }; document.head.appendChild(link); }); } // CDN URLs for lazy-loaded libraries const CDN = { mermaid: 'https://cdn.jsdelivr.net/npm/mermaid@11.15.0/dist/mermaid.min.js', mathjax: 'https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.min.js', jspdf: 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js', 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' }; 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; let isProgrammaticScrolling = false; let scrollSyncTimeout = null; const SCROLL_SYNC_DELAY = 10; // View Mode State - Story 1.1 let currentViewMode = 'split'; // 'editor', 'split', or 'preview' const APP_VERSION = '3.7.4'; let activeModal = null; let lastFocusedElement = null; let isFindModalOpen = false; let findMatches = []; let activeFindIndex = -1; let lastFindQuery = ''; // Custom Editor History State Manager variables const tabHistories = {}; let currentHistoryTabId = null; let lastPushedValue = ''; let typingTimeout = null; let lastInputType = null; // 'insert', 'delete', 'programmatic', or null 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"); const markdownFormatToolbar = document.getElementById("markdown-format-toolbar"); const themeToggle = document.getElementById("theme-toggle"); const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); const importFromGithubButton = document.getElementById("import-from-github"); const fileInput = document.getElementById("file-input"); const exportMd = document.getElementById("export-md"); const exportHtml = document.getElementById("export-html"); const exportPdf = document.getElementById("export-pdf"); const copyMarkdownButton = document.getElementById("copy-markdown-button"); const dragOverlay = document.getElementById("drag-overlay"); const toggleSyncButton = document.getElementById("toggle-sync"); const editorPane = document.getElementById("markdown-editor"); const previewPane = document.querySelector(".preview-pane"); const readingTimeElement = document.getElementById("reading-time"); const wordCountElement = document.getElementById("word-count"); const charCountElement = document.getElementById("char-count"); // View Mode Elements - Story 1.1 const contentContainer = document.querySelector(".content-container"); const viewModeButtons = document.querySelectorAll(".view-toggle-btn"); // Mobile View Mode Elements - Story 1.4 const mobileViewModeButtons = document.querySelectorAll(".mobile-view-mode-btn"); // Resize Divider Elements - Story 1.3 const resizeDivider = document.querySelector(".resize-divider"); const editorPaneElement = document.querySelector(".editor-pane"); const previewPaneElement = document.querySelector(".preview-pane"); let isResizing = false; let editorWidthPercent = 50; // Default 50% const MIN_PANE_PERCENT = 20; // Minimum 20% width const mobileMenuToggle = document.getElementById("mobile-menu-toggle"); const mobileMenuPanel = document.getElementById("mobile-menu-panel"); const mobileMenuOverlay = document.getElementById("mobile-menu-overlay"); const mobileCloseMenu = document.getElementById("close-mobile-menu"); const mobileReadingTime = document.getElementById("mobile-reading-time"); const mobileWordCount = document.getElementById("mobile-word-count"); const mobileCharCount = document.getElementById("mobile-char-count"); const mobileToggleSync = document.getElementById("mobile-toggle-sync"); const mobileImportBtn = document.getElementById("mobile-import-button"); const mobileImportGithubBtn = document.getElementById("mobile-import-github-button"); const mobileExportMd = document.getElementById("mobile-export-md"); const mobileExportHtml = document.getElementById("mobile-export-html"); const mobileExportPdf = document.getElementById("mobile-export-pdf"); const mobileCopyMarkdown = document.getElementById("mobile-copy-markdown"); const mobileThemeToggle = document.getElementById("mobile-theme-toggle"); const shareButton = document.getElementById("share-button"); const mobileShareButton = document.getElementById("mobile-share-button"); const githubImportModal = document.getElementById("github-import-modal"); const githubImportTitle = document.getElementById("github-import-title"); const githubImportUrlInput = document.getElementById("github-import-url"); const githubImportFileSelect = document.getElementById("github-import-file-select"); const githubImportSelectionToolbar = document.getElementById("github-import-selection-toolbar"); const githubImportSelectedCount = document.getElementById("github-import-selected-count"); const githubImportSelectAllBtn = document.getElementById("github-import-select-all"); const githubImportTree = document.getElementById("github-import-tree"); const githubImportError = document.getElementById("github-import-error"); const githubImportCancelBtn = document.getElementById("github-import-cancel"); const githubImportSubmitBtn = document.getElementById("github-import-submit"); const editorHighlightLayer = document.getElementById("editor-highlight-layer"); const lineNumbers = document.getElementById("line-numbers"); const clearFormattingModal = document.getElementById("clear-formatting-modal"); const clearFormattingConfirm = document.getElementById("clear-formatting-confirm"); const clearFormattingCancel = document.getElementById("clear-formatting-cancel"); const clearFormattingClose = document.getElementById("clear-formatting-close"); const findReplaceModal = document.getElementById("find-replace-modal"); const findReplaceInput = document.getElementById("find-replace-input"); const findReplaceWith = document.getElementById("find-replace-with"); const findReplaceCount = document.getElementById("find-replace-count"); const findReplacePrev = document.getElementById("find-prev"); const findReplaceNext = document.getElementById("find-next"); const findReplaceCurrent = document.getElementById("find-replace-current"); const findReplaceAll = document.getElementById("find-replace-all"); const findReplaceClose = document.getElementById("find-replace-close"); const findReplaceCloseIcon = document.getElementById("find-replace-close-icon"); const helpModal = document.getElementById("help-modal"); const helpModalClose = document.getElementById("help-modal-close"); const helpModalCloseIcon = document.getElementById("help-modal-close-icon"); const aboutModal = document.getElementById("about-modal"); const aboutModalClose = document.getElementById("about-modal-close"); const aboutModalCloseIcon = document.getElementById("about-modal-close-icon"); const aboutVersion = document.getElementById("about-version"); if (aboutVersion) { aboutVersion.textContent = APP_VERSION; } // ======================================== // GLOBAL STATE (persisted across reloads) // ======================================== const GLOBAL_STATE_KEY = 'markdownViewerGlobalState'; let referenceCounter = 1; const imageObjectUrls = new Set(); const EMOJI_API_URL = 'https://api.github.com/emojis'; let emojiLoadPromise = null; let emojiEntries = []; let emojiUrlMap = new Map(); let emojiLookupLoaded = false; let emojiRenderScheduled = false; let emojiItems = []; const emojiSelection = new Set(); let symbolItems = []; const symbolSelection = new Set(); const SYMBOL_SECTIONS = [ { title: 'Common symbols', items: [ { symbol: '©', entity: '©', name: 'copyright' }, { symbol: '®', entity: '®', name: 'registered' }, { symbol: '™', entity: '™', name: 'trademark' }, { symbol: '✓', entity: '✓', name: 'check' }, { symbol: '★', entity: '☆', name: 'star' }, { symbol: '•', entity: '•', name: 'bullet' }, { symbol: '…', entity: '…', name: 'ellipsis' }, { symbol: '—', entity: '—', name: 'em dash' }, { symbol: '–', entity: '–', name: 'en dash' }, { symbol: '→', entity: '→', name: 'right arrow' }, { symbol: '←', entity: '←', name: 'left arrow' }, { symbol: '↑', entity: '↑', name: 'up arrow' }, { symbol: '↓', entity: '↓', name: 'down arrow' }, ], }, { title: 'HTML entities', items: [ { symbol: '€', entity: '€', name: 'euro' }, { symbol: '£', entity: '£', name: 'pound' }, { symbol: '¥', entity: '¥', name: 'yen' }, { symbol: '§', entity: '§', name: 'section' }, { symbol: '°', entity: '°', name: 'degree' }, { symbol: '±', entity: '±', name: 'plus minus' }, { symbol: '×', entity: '×', name: 'times' }, { symbol: '÷', entity: '÷', name: 'divide' }, { symbol: '≠', entity: '≠', name: 'not equal' }, { symbol: '≤', entity: '≤', name: 'less equal' }, { symbol: '≥', entity: '≥', name: 'greater equal' }, { symbol: '∞', entity: '∞', name: 'infinity' }, { symbol: 'µ', entity: 'µ', name: 'micro' }, { symbol: '¼', entity: '¼', name: 'quarter' }, { symbol: '½', entity: '½', name: 'half' }, { symbol: '¾', entity: '¾', name: 'three quarters' }, { symbol: '«', entity: '«', name: 'left quote' }, { symbol: '»', entity: '»', name: 'right quote' }, ], }, { title: 'Markdown-safe characters', items: [ { symbol: '&', entity: '&', name: 'ampersand' }, { symbol: '<', entity: '<', name: 'less than' }, { symbol: '>', entity: '>', name: 'greater than' }, { symbol: '"', entity: '"', name: 'double quote' }, { symbol: "'", entity: ''', name: 'apostrophe' }, { symbol: '|', entity: '|', name: 'pipe' }, { symbol: '\\', entity: '\', name: 'backslash' }, { symbol: '`', entity: '`', name: 'backtick' }, { symbol: '*', entity: '*', name: 'asterisk' }, { symbol: '_', entity: '_', name: 'underscore' }, { symbol: '{', entity: '{', name: 'left brace' }, { symbol: '}', entity: '}', name: 'right brace' }, { symbol: '[', entity: '[', name: 'left bracket' }, { symbol: ']', entity: ']', name: 'right bracket' }, { symbol: '(', entity: '(', name: 'left parenthesis' }, { symbol: ')', entity: ')', name: 'right parenthesis' }, ], }, ]; // In-memory cache for global state to avoid repeated JSON parse/stringify round-trips (PERF-008/024) let _globalStateCache = null; function loadGlobalState() { if (_globalStateCache) return _globalStateCache; try { _globalStateCache = JSON.parse(localStorage.getItem(GLOBAL_STATE_KEY)) || {}; } catch { _globalStateCache = {}; } return _globalStateCache; } function saveGlobalState(patch) { _globalStateCache = { ...loadGlobalState(), ...patch }; localStorage.setItem(GLOBAL_STATE_KEY, JSON.stringify(_globalStateCache)); } // Check dark mode preference first for proper initialization const prefersDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; const savedTheme = loadGlobalState().theme; const initialTheme = savedTheme ?? (prefersDarkMode ? "dark" : "light"); document.documentElement.setAttribute("data-theme", initialTheme); themeToggle.innerHTML = initialTheme === "dark" ? '' : ''; function updateDirectionToggleUI(direction) { const isRtl = direction === "rtl"; const toggleLabel = isRtl ? "Switch to LTR" : "Switch to RTL"; if (directionToggle) { directionToggle.textContent = isRtl ? "R" : "L"; directionToggle.setAttribute("title", toggleLabel); directionToggle.setAttribute("aria-label", toggleLabel); directionToggle.setAttribute("aria-pressed", isRtl.toString()); } } const savedDirection = loadGlobalState().direction; const initialDirection = savedDirection === "rtl" ? "rtl" : "ltr"; function applyDirectionToContent(direction) { if (markdownEditor) markdownEditor.setAttribute("dir", direction); if (markdownPreview) markdownPreview.setAttribute("dir", direction); } applyDirectionToContent(initialDirection); updateDirectionToggleUI(initialDirection); // Track last Mermaid theme to avoid redundant re-initialization (PERF-005) let _lastMermaidTheme = null; let _mermaidThemeReinitTimeout = null; let _themeTransitionTimeout = null; const initMermaid = (forceReinit) => { if (typeof mermaid === 'undefined') return; // PERF-002: Not loaded yet const currentTheme = document.documentElement.getAttribute("data-theme"); const mermaidTheme = currentTheme === "dark" ? "dark" : "default"; // Skip re-initialization if theme hasn't changed (PERF-005) if (!forceReinit && _lastMermaidTheme === mermaidTheme) return; _lastMermaidTheme = mermaidTheme; mermaid.initialize({ startOnLoad: false, theme: mermaidTheme, securityLevel: 'strict', flowchart: { useMaxWidth: true, htmlLabels: true }, fontSize: 16 }); }; // PERF-002: Removed eager initMermaid() — mermaid is now lazy-loaded on first use const markedOptions = { gfm: true, breaks: true, pedantic: false, sanitize: false, smartypants: false, xhtml: false, headerIds: true, mangle: false, }; 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; 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*$/; const footnoteDefinitions = new Map(); const footnoteOrder = []; const footnoteRefCounts = new Map(); const footnoteFirstRefId = new Map(); let anonymousFootnoteCounter = 0; let suppressFootnotePreprocess = false; 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 escapeHtmlAttribute(value) { return String(value) .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .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 (/^\s*---\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 { return marked.parseInline(text); } finally { suppressFootnotePreprocess = false; } } function renderDefinitionContent(content, options = {}) { const { appendHtml = "" } = options; 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) => { const renderedParagraph = parseInlineWithoutFootnotes(paragraph); if (typeof DOMPurify === "undefined") { throw new ReferenceError("DOMPurify is not defined. Secure rendering aborted."); } const safeParagraph = DOMPurify.sanitize(renderedParagraph); return `

${safeParagraph}

`; }) .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; const safeRefId = escapeHtmlAttribute(refId); const safeNormalizedId = escapeHtmlAttribute(normalizedId); return `[${noteNumber}]`; }); const footnotesHtml = footnoteOrder .filter((id) => footnoteDefinitions.has(id)) .map((id) => { const normalizedId = normalizeFootnoteId(id); const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; const safeNormalizedId = escapeHtmlAttribute(normalizedId); const safeBackRefId = escapeHtmlAttribute(backRefId); const backRefHtml = ``; const noteHtml = renderDefinitionContent( footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml } ); return `
  • ${noteHtml}
  • `; }) .join(""); if (!footnotesHtml) { return markdownWithReferences; } return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; } const blockMathExtension = { name: 'blockMath', level: 'block', start(src) { const match = src.match(BLOCK_MATH_MARKER_PATTERN); if (!match) { return undefined; } return match.index; }, 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]+/); if (!match) { return undefined; } return match.index + 1; }, 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: raw, term: term.trim(), definitions: 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); if (!match) { return undefined; } return { type: "superscript", raw: match[0], text: match[1], }; }, 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); if (!match) { return undefined; } return { type: "subscript", raw: match[0], text: match[1], }; }, 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); if (!match) { return undefined; } return { type: "highlight", raw: match[0], text: match[1], }; }, renderer(token) { return `${marked.parseInline(token.text)}`; }, }; renderer.code = function (code, language) { if (language === 'mermaid') { const uniqueId = 'mermaid-diagram-' + 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, { language: validLanguage, }).value; return `
    ${highlightedCode}
    `; }; renderer.heading = function (text, level, raw) { let id = raw .toLowerCase() .trim() .replace(/<[^>]*>/g, '') .replace(/\s+/g, '-') .replace(/[^\w-]/g, '') .replace(/-+/g, '-'); if (!id) { id = 'heading-' + Math.random().toString(36).substr(2, 9); } return `${text}`; }; marked.use({ extensions: [ blockMathExtension, definitionListExtension, superscriptExtension, subscriptExtension, highlightExtension, ], hooks: { preprocess(markdown) { if (suppressFootnotePreprocess) { return markdown; } resetExtendedMarkdownState(); // ✅ Replace escaped dollar signs before marked.js strips the backslash. // This prevents MathJax from treating lone $ as a math delimiter. const protectedMarkdown = markdown.replace(/\\\$/g, '$'); return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); }, }, }); marked.setOptions({ ...markedOptions, renderer: renderer, }); const GITHUB_ALERT_META = { note: { label: "Note", viewBox: "0 0 512 512", path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z", }, tip: { label: "Tip", viewBox: "0 0 384 512", path: "M297.2 248.9C311.6 228.3 320 203.2 320 176c0-70.7-57.3-128-128-128S64 105.3 64 176c0 27.2 8.4 52.3 22.8 72.9c3.7 5.3 8.1 11.3 12.8 17.7c0 0 0 0 0 0c12.9 17.7 28.3 38.9 39.8 59.8c10.4 19 15.7 38.8 18.3 57.5L109 384c-2.2-12-5.9-23.7-11.8-34.5c-9.9-18-22.2-34.9-34.5-51.8c0 0 0 0 0 0s0 0 0 0c-5.2-7.1-10.4-14.2-15.4-21.4C27.6 247.9 16 213.3 16 176C16 78.8 94.8 0 192 0s176 78.8 176 176c0 37.3-11.6 71.9-31.4 100.3c-5 7.2-10.2 14.3-15.4 21.4c0 0 0 0 0 0s0 0 0 0c-12.3 16.8-24.6 33.7-34.5 51.8c-5.9 10.8-9.6 22.5-11.8 34.5l-48.6 0c2.6-18.7 7.9-38.6 18.3-57.5c11.5-20.9 26.9-42.1 39.8-59.8c0 0 0 0 0 0s0 0 0 0s0 0 0 0c4.7-6.4 9-12.4 12.7-17.7zM192 128c-26.5 0-48 21.5-48 48c0 8.8-7.2 16-16 16s-16-7.2-16-16c0-44.2 35.8-80 80-80c8.8 0 16 7.2 16 16s-7.2 16-16 16zm0 384c-44.2 0-80-35.8-80-80l0-16 160 0 0 16c0 44.2-35.8 80-80 80z", }, important: { label: "Important", viewBox: "0 0 512 512", path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z", }, warning: { label: "Warning", viewBox: "0 0 512 512", path: "M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z", }, caution: { label: "Caution", viewBox: "0 0 512 512", path: "M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z", }, }; const GITHUB_ALERT_MARKER_REGEX = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:(?:\s| |)+|$)/i; function enhanceGitHubAlerts(container) { if (!container) return; const blockquotes = container.querySelectorAll("blockquote"); blockquotes.forEach((blockquote) => { let firstParagraph = null; for (const child of blockquote.children) { if (child.tagName === "P") { firstParagraph = child; break; } } if (!firstParagraph) return; const firstParagraphHtml = firstParagraph.innerHTML.trim(); const markerMatch = firstParagraphHtml.match(GITHUB_ALERT_MARKER_REGEX); if (!markerMatch) return; const alertType = markerMatch[1].toLowerCase(); blockquote.classList.add("markdown-alert", `markdown-alert-${alertType}`); const title = document.createElement("p"); title.className = "markdown-alert-title"; const alertMeta = GITHUB_ALERT_META[alertType] || { label: markerMatch[1], path: "" }; const icon = document.createElement("span"); icon.className = "markdown-alert-icon"; icon.setAttribute("aria-hidden", "true"); if (alertMeta.path) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", alertMeta.viewBox || "0 0 512 512"); const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); path.setAttribute("d", alertMeta.path); svg.appendChild(path); icon.appendChild(svg); } const label = document.createElement("span"); label.textContent = alertMeta.label; title.appendChild(icon); title.appendChild(label); blockquote.insertBefore(title, blockquote.firstChild); const remainingHtml = firstParagraphHtml .replace(GITHUB_ALERT_MARKER_REGEX, "") .trim(); if (remainingHtml) { firstParagraph.innerHTML = remainingHtml; } else { firstParagraph.remove(); } }); } function parseFrontmatter(markdown) { const match = markdown.match(/^\s*---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/); if (!match) return { frontmatter: null, body: markdown }; try { const data = jsyaml.load(match[1]) || {}; return { frontmatter: data, body: markdown.slice(match[0].length) }; } catch (e) { console.warn('Frontmatter YAML parse error:', e); return { frontmatter: null, body: markdown }; } } function renderFrontmatterValue(value) { if (value === null || value === undefined) return ''; if (value instanceof Date) { const y = value.getUTCFullYear(); const m = String(value.getUTCMonth() + 1).padStart(2, '0'); const d = String(value.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } if (Array.isArray(value)) { const allPrimitive = value.every(v => v === null || typeof v !== 'object'); if (allPrimitive) { return value .map(v => `${escapeHtml(String(v ?? ''))}`) .join(''); } return `
    ${escapeHtml(jsyaml.dump(value).trimEnd())}
    `; } if (typeof value === 'object') { return `
    ${escapeHtml(jsyaml.dump(value).trimEnd())}
    `; } return escapeHtml(String(value)); } function renderFrontmatterTable(data) { const rows = Object.entries(data).map(([key, value]) => `${escapeHtml(key)}${renderFrontmatterValue(value)}` ); return `${rows.join('')}
    `; } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // PERF-012: Inlined default template to eliminate network request, FOUC, and layout shifts const defaultMarkdownTemplate = document.getElementById('default-markdown'); let templateText = ''; if (defaultMarkdownTemplate) { if (defaultMarkdownTemplate.content && typeof defaultMarkdownTemplate.content.textContent === 'string') { templateText = defaultMarkdownTemplate.content.textContent.trim(); } else { templateText = defaultMarkdownTemplate.textContent ? defaultMarkdownTemplate.textContent.trim() : ''; } } const sampleMarkdown = templateText || '# Welcome to Markdown Viewer\n\nStart typing your markdown here...'; if (!markdownEditor.value) { markdownEditor.value = sampleMarkdown; } // ======================================== // DOCUMENT TABS & SESSION MANAGEMENT // ======================================== const STORAGE_KEY = 'markdownViewerTabs'; const ACTIVE_TAB_KEY = 'markdownViewerActiveTab'; const UNTITLED_COUNTER_KEY = 'markdownViewerUntitledCounter'; let tabs = []; let activeTabId = null; let draggedTabId = null; let saveTabStateTimeout = null; let untitledCounter = 0; function loadTabsFromStorage() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } catch (e) { return []; } } function saveTabsToStorage(tabsArr) { // PERF-008: Debounce tab saves to reduce main thread blocking from JSON.stringify // on large document arrays. Immediate flush happens on visibilitychange/beforeunload. clearTimeout(saveTabStateTimeout); saveTabStateTimeout = setTimeout(function() { _flushTabsToStorage(tabsArr); }, 500); } function _flushTabsToStorage(tabsArr) { clearTimeout(saveTabStateTimeout); try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tabsArr || tabs)); } catch (e) { console.warn('Failed to save tabs to localStorage:', e); } } // Ensure tabs are persisted before page close (PERF-008) window.addEventListener('beforeunload', function() { _flushTabsToStorage(tabs); }); document.addEventListener('visibilitychange', function() { if (document.visibilityState === 'hidden') _flushTabsToStorage(tabs); }); function loadActiveTabId() { return localStorage.getItem(ACTIVE_TAB_KEY); } function saveActiveTabId(id) { localStorage.setItem(ACTIVE_TAB_KEY, id); } function loadUntitledCounter() { return parseInt(localStorage.getItem(UNTITLED_COUNTER_KEY) || '0', 10); } function saveUntitledCounter(val) { localStorage.setItem(UNTITLED_COUNTER_KEY, String(val)); } function nextUntitledTitle() { untitledCounter += 1; saveUntitledCounter(untitledCounter); return 'Untitled ' + untitledCounter; } function createTab(content, title, viewMode) { if (content === undefined) content = ''; if (title === undefined) title = null; if (viewMode === undefined) viewMode = 'split'; return { id: 'tab_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8), title: title || 'Untitled', content: content, scrollPos: 0, viewMode: viewMode, createdAt: Date.now() }; } function closeTabMenus() { document.querySelectorAll('.tab-menu-btn.open').forEach(function(btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }); document.querySelectorAll('.tab-menu-dropdown.open').forEach(function(dropdown) { dropdown.classList.remove('open'); }); } function removeTabMenuDropdowns() { document.querySelectorAll('.tab-menu-dropdown[data-tab-menu-dropdown="true"]').forEach(function(dropdown) { dropdown.remove(); }); } function positionTabMenu(menuBtn, dropdown) { const rect = menuBtn.getBoundingClientRect(); const margin = 8; const dropdownWidth = dropdown.offsetWidth || 130; const dropdownHeight = dropdown.offsetHeight || 110; let left = rect.right - dropdownWidth; let top = rect.bottom + 4; left = Math.max(margin, Math.min(left, window.innerWidth - dropdownWidth - margin)); if (top + dropdownHeight > window.innerHeight - margin) { top = Math.max(margin, rect.top - dropdownHeight - 4); } dropdown.style.top = top + 'px'; dropdown.style.left = left + 'px'; dropdown.style.right = 'auto'; } function runTabMenuAction(tabId, action, isMobileMenu) { if (action === 'rename') { if (isMobileMenu) closeMobileMenu(); renameTab(tabId); } else if (action === 'duplicate') { duplicateTab(tabId); if (isMobileMenu) closeMobileMenu(); } else if (action === 'delete') { deleteTab(tabId); } } function createTabActionMenu(tab, options) { const isMobileMenu = options && options.isMobileMenu; const menuIdPrefix = options && options.menuIdPrefix ? options.menuIdPrefix : 'tab-menu'; const menuId = menuIdPrefix + '-' + tab.id; const menuBtn = document.createElement('button'); menuBtn.type = 'button'; menuBtn.className = 'tab-menu-btn'; menuBtn.setAttribute('aria-label', 'File options for ' + (tab.title || 'Untitled')); menuBtn.setAttribute('aria-haspopup', 'menu'); menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.setAttribute('aria-controls', menuId); menuBtn.setAttribute('draggable', 'false'); menuBtn.title = 'File options'; // PERF-007: Replace HTML entity with plain unicode character set via textContent menuBtn.textContent = '⋯'; const dropdown = document.createElement('div'); dropdown.id = menuId; dropdown.className = 'tab-menu-dropdown'; dropdown.setAttribute('data-tab-menu-dropdown', 'true'); dropdown.setAttribute('role', 'menu'); dropdown.innerHTML = '' + '' + ''; menuBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); const shouldOpen = !menuBtn.classList.contains('open'); closeTabMenus(); if (shouldOpen) { menuBtn.classList.add('open'); menuBtn.setAttribute('aria-expanded', 'true'); dropdown.classList.add('open'); positionTabMenu(menuBtn, dropdown); } }); menuBtn.addEventListener('mousedown', function(e) { e.stopPropagation(); }); menuBtn.addEventListener('dragstart', function(e) { e.preventDefault(); e.stopPropagation(); }); dropdown.addEventListener('click', function(e) { e.stopPropagation(); }); dropdown.querySelectorAll('.tab-menu-item').forEach(function(actionBtn) { actionBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); const action = actionBtn.getAttribute('data-action'); closeTabMenus(); runTabMenuAction(tab.id, action, isMobileMenu); }); }); document.body.appendChild(dropdown); return { button: menuBtn, dropdown: dropdown }; } function renderTabBar(tabsArr, currentActiveTabId) { const tabList = document.getElementById('tab-list'); if (!tabList) return; closeTabMenus(); removeTabMenuDropdowns(); // PERF-007: Use textContent instead of innerHTML to clear elements faster tabList.textContent = ''; tabsArr.forEach(function(tab) { const item = document.createElement('div'); item.className = 'tab-item' + (tab.id === currentActiveTabId ? ' active' : ''); item.setAttribute('data-tab-id', tab.id); item.setAttribute('role', 'tab'); item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false'); item.setAttribute('draggable', 'true'); item.setAttribute('tabindex', tab.id === currentActiveTabId ? '0' : '-1'); const titleSpan = document.createElement('span'); titleSpan.className = 'tab-title'; titleSpan.textContent = tab.title || 'Untitled'; titleSpan.title = tab.title || 'Untitled'; const tabMenu = createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' }); item.appendChild(titleSpan); item.appendChild(tabMenu.button); item.addEventListener('dragstart', function() { draggedTabId = tab.id; setTimeout(function() { item.classList.add('dragging'); }, 0); }); item.addEventListener('dragend', function() { item.classList.remove('dragging'); draggedTabId = null; }); item.addEventListener('dragover', function(e) { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragleave', function() { item.classList.remove('drag-over'); }); item.addEventListener('drop', function(e) { e.preventDefault(); item.classList.remove('drag-over'); if (!draggedTabId || draggedTabId === tab.id) return; const fromIdx = tabs.findIndex(function(t) { return t.id === draggedTabId; }); const toIdx = tabs.findIndex(function(t) { return t.id === tab.id; }); if (fromIdx === -1 || toIdx === -1) return; const moved = tabs.splice(fromIdx, 1)[0]; tabs.splice(toIdx, 0, moved); saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); }); tabList.appendChild(item); }); // PERF-006: Event delegation — single click handler for all tabs tabList.onclick = function(e) { const tabItem = e.target.closest('.tab-item'); if (!tabItem) return; // Don't switch tab if clicking the menu button if (e.target.closest('.tab-menu-btn')) return; const tabId = tabItem.getAttribute('data-tab-id'); if (tabId) switchTab(tabId); }; // Auto-scroll active tab into view (paint-aligned to prevent forced reflows) const activeItem = tabList.querySelector('.tab-item.active'); if (activeItem) { requestAnimationFrame(function() { activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }); } // Arrow-key keyboard navigation inside tabList (WAI-ARIA compliance with manual selection) tabList.onkeydown = function(e) { const items = Array.from(tabList.querySelectorAll('.tab-item')); if (items.length === 0) return; const focusedItem = document.activeElement.closest('.tab-item'); if (!focusedItem) return; const activeIdx = items.indexOf(focusedItem); if (activeIdx === -1) return; let targetIdx = -1; if (e.key === 'ArrowRight') { targetIdx = (activeIdx + 1) % items.length; } else if (e.key === 'ArrowLeft') { targetIdx = (activeIdx - 1 + items.length) % items.length; } else if (e.key === 'Home') { targetIdx = 0; } else if (e.key === 'End') { targetIdx = items.length - 1; } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const tabId = focusedItem.getAttribute('data-tab-id'); switchTab(tabId); // After switch tab, focus the active item in the newly rendered tab bar requestAnimationFrame(function() { const newActive = tabList.querySelector(`.tab-item[data-tab-id="${tabId}"]`); if (newActive) newActive.focus(); }); return; } if (targetIdx !== -1) { e.preventDefault(); // Update roving tabindex focus without triggering heavy re-renders items.forEach(function(item, idx) { if (idx === targetIdx) { item.setAttribute('tabindex', '0'); item.focus(); } else { item.setAttribute('tabindex', '-1'); } }); } }; renderMobileTabList(tabsArr, currentActiveTabId); if (typeof tabList.dispatchEvent === 'function') { tabList.dispatchEvent(new Event('scroll')); } } // ======================================== // TAB OVERFLOW — Scroll Buttons, Wheel, Indicators // ======================================== var _tabOverflowInitialized = false; function setupTabOverflow() { if (_tabOverflowInitialized) return; _tabOverflowInitialized = true; var tabBar = document.getElementById('tab-bar'); var tabList = document.getElementById('tab-list'); if (!tabBar || !tabList) return; // --- Create scroll arrow buttons --- var scrollLeftBtn = document.createElement('button'); scrollLeftBtn.className = 'tab-scroll-btn tab-scroll-left'; scrollLeftBtn.setAttribute('aria-label', 'Scroll tabs left'); scrollLeftBtn.title = 'Scroll left'; scrollLeftBtn.innerHTML = ''; scrollLeftBtn.addEventListener('click', function() { tabList.scrollBy({ left: -200, behavior: 'smooth' }); }); var scrollRightBtn = document.createElement('button'); scrollRightBtn.className = 'tab-scroll-btn tab-scroll-right'; scrollRightBtn.setAttribute('aria-label', 'Scroll tabs right'); scrollRightBtn.title = 'Scroll right'; scrollRightBtn.innerHTML = ''; scrollRightBtn.addEventListener('click', function() { tabList.scrollBy({ left: 200, behavior: 'smooth' }); }); // Insert scroll buttons flanking the tab-list tabBar.insertBefore(scrollLeftBtn, tabList); var newBtn = document.getElementById('tab-new-btn'); if (newBtn) { tabBar.insertBefore(scrollRightBtn, newBtn); } else { var resetBtn = document.getElementById('tab-reset-btn'); if (resetBtn) { tabBar.insertBefore(scrollRightBtn, resetBtn); } else { tabBar.appendChild(scrollRightBtn); } } // --- Overflow detection --- var _overflowRafId = null; function updateOverflowState() { if (_overflowRafId) return; _overflowRafId = requestAnimationFrame(function() { _overflowRafId = null; var hasLeft = tabList.scrollLeft > 1; var hasRight = tabList.scrollLeft < (tabList.scrollWidth - tabList.clientWidth - 1); tabBar.classList.toggle('has-overflow-left', hasLeft); tabBar.classList.toggle('has-overflow-right', hasRight); }); } tabList.addEventListener('scroll', updateOverflowState); // Use ResizeObserver to detect when overflow state changes due to window resize if (typeof ResizeObserver !== 'undefined') { var resizeObs = new ResizeObserver(updateOverflowState); resizeObs.observe(tabList); } // Initial check updateOverflowState(); // --- Mouse wheel scroll: vertical wheel → horizontal scroll --- tabList.addEventListener('wheel', function(e) { // Only intercept vertical wheel (don't fight native horizontal wheel/trackpad) if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.preventDefault(); tabList.scrollLeft += e.deltaY; updateOverflowState(); } }, { passive: false }); } function renderMobileTabList(tabsArr, currentActiveTabId) { const mobileTabList = document.getElementById('mobile-tab-list'); if (!mobileTabList) return; // PERF-007: Clear element content using textContent instead of innerHTML mobileTabList.textContent = ''; tabsArr.forEach(function(tab) { const item = document.createElement('div'); item.className = 'mobile-tab-item' + (tab.id === currentActiveTabId ? ' active' : ''); item.setAttribute('role', 'tab'); item.setAttribute('aria-selected', tab.id === currentActiveTabId ? 'true' : 'false'); item.setAttribute('data-tab-id', tab.id); const titleSpan = document.createElement('span'); titleSpan.className = 'mobile-tab-title'; titleSpan.textContent = tab.title || 'Untitled'; titleSpan.title = tab.title || 'Untitled'; const tabMenu = createTabActionMenu(tab, { isMobileMenu: true, menuIdPrefix: 'mobile-tab-menu' }); item.appendChild(titleSpan); item.appendChild(tabMenu.button); item.addEventListener('click', function() { switchTab(tab.id); closeMobileMenu(); }); mobileTabList.appendChild(item); }); } // Close any open tab dropdown when clicking elsewhere in the document document.addEventListener('click', function() { closeTabMenus(); }); function saveCurrentTabState() { const tab = tabs.find(function(t) { return t.id === activeTabId; }); if (!tab) return; tab.content = markdownEditor.value; tab.scrollPos = markdownEditor.scrollTop; tab.viewMode = currentViewMode || 'split'; saveTabsToStorage(tabs); } function restoreViewMode(mode) { currentViewMode = null; setViewMode(mode || 'split'); } function switchTab(tabId) { if (tabId === activeTabId) return; saveCurrentTabState(); // Clear typing timeout and reset tracking for the new tab if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } lastInputType = null; pendingState = null; activeTabId = tabId; saveActiveTabId(activeTabId); const tab = tabs.find(function(t) { return t.id === tabId; }); if (!tab) return; markdownEditor.value = tab.content; initTabHistory(tabId, tab.content); lastPushedValue = tab.content; currentHistoryTabId = tabId; updateUndoRedoButtons(); restoreViewMode(tab.viewMode); renderMarkdown(); requestAnimationFrame(function() { markdownEditor.scrollTop = tab.scrollPos || 0; }); renderTabBar(tabs, activeTabId); } function newTab(content, title) { if (content === undefined) content = ''; if (tabs.length >= 20) { alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); return; } if (!title) title = nextUntitledTitle(); const tab = createTab(content, title); tabs.push(tab); switchTab(tab.id); markdownEditor.focus(); } function closeTab(tabId) { const idx = tabs.findIndex(function(t) { return t.id === tabId; }); if (idx === -1) return; // Clean up history of the closed tab if (tabHistories[tabId]) { delete tabHistories[tabId]; } tabs.splice(idx, 1); if (tabs.length === 0) { // Auto-create new "Untitled" when last tab is deleted const newT = createTab('', nextUntitledTitle()); tabs.push(newT); activeTabId = newT.id; saveActiveTabId(activeTabId); markdownEditor.value = ''; restoreViewMode('split'); renderMarkdown(); } else if (activeTabId === tabId) { const newIdx = Math.max(0, idx - 1); activeTabId = tabs[newIdx].id; saveActiveTabId(activeTabId); const newActiveTab = tabs[newIdx]; markdownEditor.value = newActiveTab.content; restoreViewMode(newActiveTab.viewMode); renderMarkdown(); requestAnimationFrame(function() { markdownEditor.scrollTop = newActiveTab.scrollPos || 0; }); } saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); } function deleteTab(tabId) { closeTab(tabId); } function renameTab(tabId) { const tab = tabs.find(function(t) { return t.id === tabId; }); if (!tab) return; const modal = document.getElementById('rename-modal'); const input = document.getElementById('rename-modal-input'); const confirmBtn = document.getElementById('rename-modal-confirm'); const cancelBtn = document.getElementById('rename-modal-cancel'); if (!modal || !input) return; input.value = tab.title; function doRename() { const newName = input.value.trim(); if (newName) { tab.title = newName; saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); } closeAppModal(modal); cleanup(); } function doCancel() { closeAppModal(modal); cleanup(); } function onKey(e) { if (e.key === 'Enter') doRename(); } function cleanup() { confirmBtn.removeEventListener('click', doRename); cancelBtn.removeEventListener('click', doCancel); input.removeEventListener('keydown', onKey); } confirmBtn.addEventListener('click', doRename); cancelBtn.addEventListener('click', doCancel); input.addEventListener('keydown', onKey); openAppModal(modal, { focusTarget: input, onClose: doCancel }); input.select(); } function duplicateTab(tabId) { const tab = tabs.find(function(t) { return t.id === tabId; }); if (!tab) return; if (tabs.length >= 20) { alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.'); return; } const shouldSwitchToDuplicate = tabId === activeTabId; saveCurrentTabState(); const dupTitle = tab.title + ' (copy)'; const dup = createTab(tab.content, dupTitle, tab.viewMode); const idx = tabs.findIndex(function(t) { return t.id === tabId; }); tabs.splice(idx + 1, 0, dup); if (shouldSwitchToDuplicate) { switchTab(dup.id); } else { saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); } } function resetAllTabs() { const modal = document.getElementById('reset-confirm-modal'); const confirmBtn = document.getElementById('reset-modal-confirm'); const cancelBtn = document.getElementById('reset-modal-cancel'); if (!modal) return; function doReset() { closeAppModal(modal); cleanup(); tabs = []; untitledCounter = 0; saveUntitledCounter(0); const welcome = createTab(sampleMarkdown, 'Welcome to Markdown'); tabs.push(welcome); activeTabId = welcome.id; saveActiveTabId(activeTabId); saveTabsToStorage(tabs); markdownEditor.value = sampleMarkdown; restoreViewMode('split'); renderMarkdown(); renderTabBar(tabs, activeTabId); } function doCancel() { closeAppModal(modal); cleanup(); } function cleanup() { confirmBtn.removeEventListener('click', doReset); cancelBtn.removeEventListener('click', doCancel); } confirmBtn.addEventListener('click', doReset); cancelBtn.addEventListener('click', doCancel); openAppModal(modal, { focusTarget: confirmBtn, onClose: doCancel }); } function initTabs() { untitledCounter = loadUntitledCounter(); tabs = loadTabsFromStorage(); activeTabId = loadActiveTabId(); // Check if Neutralino passed an initial file via command line (early load) if (window.NL_INITIAL_FILE_CONTENT) { const initialFile = window.NL_INITIAL_FILE_CONTENT; const tab = createTab(initialFile.content, initialFile.name); tabs.push(tab); activeTabId = tab.id; saveTabsToStorage(tabs); saveActiveTabId(activeTabId); delete window.NL_INITIAL_FILE_CONTENT; } else if (tabs.length === 0) { const tab = createTab(sampleMarkdown, 'Welcome to Markdown'); tabs.push(tab); activeTabId = tab.id; saveTabsToStorage(tabs); saveActiveTabId(activeTabId); } else if (!tabs.find(function(t) { return t.id === activeTabId; })) { activeTabId = tabs[0].id; saveActiveTabId(activeTabId); } const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); markdownEditor.value = activeTab.content; initTabHistory(activeTabId, activeTab.content); updateUndoRedoButtons(); restoreViewMode(activeTab.viewMode); renderMarkdown(); const editorPane = document.querySelector('.editor-pane'); if (editorPane) { editorPane.classList.remove('is-loading'); } requestAnimationFrame(function() { markdownEditor.scrollTop = activeTab.scrollPos || 0; }); renderTabBar(tabs, activeTabId); setupTabOverflow(); const staticNewBtn = document.getElementById('tab-new-btn'); if (staticNewBtn) { staticNewBtn.onclick = function() { newTab(); }; } } // Late-load callback hook for Neutralino command-line files window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { if (typeof tabs === 'undefined') return; const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); if (existing) { switchTab(existing.id); return; } newTab(content, name); }; function showPreviewSkeleton() { if (markdownPreview && !markdownPreview.querySelector('#markdown-preview-skeleton')) { markdownPreview.setAttribute('aria-busy', 'true'); markdownPreview.dataset.renderState = 'loading'; markdownPreview.innerHTML = ` `; } } function previewContainsSkeleton() { return Boolean(markdownPreview && markdownPreview.querySelector('#markdown-preview-skeleton')); } function getActivePreviewDocumentId() { return activeTabId || '__single-document__'; } function clearPendingPreviewWork() { if (pendingPreviewRenderCancel) { pendingPreviewRenderCancel(); pendingPreviewRenderCancel = null; } } function getPreviewRenderDelay(markdown) { const length = markdown.length; if (length >= HUGE_DOCUMENT_THRESHOLD) return HUGE_RENDER_DELAY; if (length >= LARGE_DOCUMENT_THRESHOLD) return LARGE_RENDER_DELAY; return RENDER_DELAY; } function getEditorWorkDelay(markdown) { const length = markdown.length; if (length >= HUGE_DOCUMENT_THRESHOLD) return HUGE_EDITOR_WORK_DELAY; if (length >= LARGE_DOCUMENT_THRESHOLD) return LARGE_EDITOR_WORK_DELAY; return 0; } function isEditorVisible() { return currentViewMode === 'editor' || currentViewMode === 'split'; } function deferPreviewWork(callback, rawLength) { let cancelled = false; let rafId = null; let idleId = null; let timeoutId = null; rafId = requestAnimationFrame(function() { rafId = null; if (cancelled) return; if ('requestIdleCallback' in window) { idleId = window.requestIdleCallback(function() { idleId = null; if (!cancelled) callback(); }, { timeout: rawLength >= HUGE_DOCUMENT_THRESHOLD ? 700 : 350 }); } else { timeoutId = setTimeout(function() { timeoutId = null; if (!cancelled) callback(); }, 0); } }); return function cancelDeferredPreviewWork() { cancelled = true; if (rafId !== null) cancelAnimationFrame(rafId); if (idleId !== null && 'cancelIdleCallback' in window) window.cancelIdleCallback(idleId); if (timeoutId !== null) clearTimeout(timeoutId); }; } function capturePreviewScroll() { if (!previewPane) return null; return { top: previewPane.scrollTop, left: previewPane.scrollLeft, }; } function restorePreviewScroll(snapshot) { if (!snapshot || !previewPane) return; requestAnimationFrame(function() { const maxTop = Math.max(0, previewPane.scrollHeight - previewPane.clientHeight); previewPane.scrollTop = Math.min(maxTop, snapshot.top); previewPane.scrollLeft = snapshot.left; }); } function canReusePreviewNode(currentNode, nextNode, options) { if (!currentNode || !nextNode || currentNode.nodeType !== nextNode.nodeType) return false; if (currentNode.nodeType === Node.TEXT_NODE) { if (currentNode.nodeValue !== nextNode.nodeValue) { currentNode.nodeValue = nextNode.nodeValue; } return true; } if (currentNode.nodeType !== Node.ELEMENT_NODE) { return currentNode.nodeValue === nextNode.nodeValue; } if (currentNode.nodeName !== nextNode.nodeName) return false; const currentEl = currentNode; const nextEl = nextNode; if ((currentEl.id || nextEl.id) && currentEl.id !== nextEl.id) return false; if ( options && options.reusePreviewBlocks && currentEl.dataset && nextEl.dataset && currentEl.dataset.previewBlockHash && currentEl.dataset.previewBlockHash === nextEl.dataset.previewBlockHash ) { return true; } if (currentEl.outerHTML === nextEl.outerHTML) return true; if (currentEl.tagName === 'DETAILS' && nextEl.tagName === 'DETAILS' && currentEl.hasAttribute('open')) { nextEl.setAttribute('open', ''); } return false; } function patchPreviewDom(container, html, options) { const result = { fullReplace: false, updatedNodes: [], }; if (!previewHasCommittedRender || previewContainsSkeleton()) { container.innerHTML = html; result.fullReplace = true; result.updatedNodes = [container]; return result; } const template = document.createElement('template'); template.innerHTML = html; const nextNodes = Array.from(template.content.childNodes); const currentNodeCount = container.childNodes.length; if (nextNodes.length > 6000 || currentNodeCount > 6000) { container.replaceChildren(...nextNodes); result.fullReplace = true; result.updatedNodes = [container]; return result; } let index = 0; while (index < nextNodes.length || index < container.childNodes.length) { const currentNode = container.childNodes[index]; const nextNode = nextNodes[index]; if (!nextNode) { currentNode.remove(); continue; } if (!currentNode) { container.appendChild(nextNode); result.updatedNodes.push(nextNode); index += 1; continue; } if (canReusePreviewNode(currentNode, nextNode, options)) { index += 1; continue; } result.updatedNodes.push(nextNode); currentNode.replaceWith(nextNode); index += 1; } return result; } function commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context) { const shouldRestoreScroll = previewHasCommittedRender && !previewContainsSkeleton(); const scrollSnapshot = shouldRestoreScroll ? capturePreviewScroll() : null; const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, }); applyReferencePreviewLinks(markdownPreview, referenceData.definitions); enhanceGitHubAlerts(markdownPreview); _lastRenderedContent = rawVal; previewHasCommittedRender = true; previewLastRenderedTabId = context.previewDocumentId; markdownPreview.removeAttribute('aria-busy'); markdownPreview.dataset.renderState = 'ready'; restorePreviewScroll(scrollSnapshot); return patchResult; } function getPreviewPostProcessRoots(patchResult) { if (!patchResult || patchResult.fullReplace || !patchResult.updatedNodes || patchResult.updatedNodes.length === 0) { return [markdownPreview]; } const roots = []; const seen = new Set(); patchResult.updatedNodes.forEach(function(node) { const root = node && node.nodeType === Node.ELEMENT_NODE ? node : (node && node.parentElement); if (root && !seen.has(root)) { seen.add(root); roots.push(root); } }); return roots.length ? roots : [markdownPreview]; } function queryPreviewRoots(roots, selector) { const matches = []; const seen = new Set(); roots.forEach(function(root) { if (!root || root.nodeType !== Node.ELEMENT_NODE) return; if (root.matches && root.matches(selector) && !seen.has(root)) { seen.add(root); matches.push(root); } root.querySelectorAll(selector).forEach(function(node) { if (!seen.has(node)) { seen.add(node); matches.push(node); } }); }); return matches; } function markPreviewRootsReady(roots) { queryPreviewRoots(roots, '.mermaid-container.is-loading').forEach(function(container) { container.classList.remove('is-loading'); }); } function postProcessPreview(rawVal, context, patchResult) { const roots = getPreviewPostProcessRoots(patchResult); roots.forEach(function(root) { processEmojis(root); }); queryPreviewRoots(roots, 'input[type="checkbox"]').forEach(function(input) { if (!input.hasAttribute('aria-label')) { const parentText = input.parentElement ? input.parentElement.textContent.trim() : ''; input.setAttribute('aria-label', parentText || 'Task item'); } }); try { const mermaidNodes = queryPreviewRoots(roots, '.mermaid'); if (mermaidNodes.length > 0) { const renderMermaidNodes = function() { if (context.renderId !== previewRenderGeneration) return; initMermaid(false); Promise.resolve(mermaid.init(undefined, mermaidNodes)) .then(() => { if (context.renderId !== previewRenderGeneration) return; markPreviewRootsReady(roots); addMermaidToolbars(); }) .catch((e) => { if (context.renderId !== previewRenderGeneration) return; console.warn("Mermaid rendering failed:", e); markPreviewRootsReady(roots); addMermaidToolbars(); }); }; if (typeof mermaid === 'undefined') { loadScript(CDN.mermaid).then(function() { if (context.renderId !== previewRenderGeneration) return; initMermaid(true); renderMermaidNodes(); }).catch(function(e) { console.warn('Failed to load mermaid:', e); }); } else { renderMermaidNodes(); } } } catch (e) { console.warn("Mermaid rendering failed:", e); } const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || ''); if (hasMath) { const typesetTargets = roots.filter(function(root) { return root && root.nodeType === Node.ELEMENT_NODE && /\$\$|\$[^$]|\\\(|\\\[/.test(root.textContent || ''); }); const mathTargets = typesetTargets.length ? typesetTargets : roots; if (window.MathJax) { try { MathJax.typesetPromise(mathTargets).then(function() { if (context.renderId !== previewRenderGeneration) return; queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { mjx.removeAttribute('tabindex'); }); }).catch(function(err) { console.warn('MathJax typesetting failed:', err); }); } catch (e) { console.warn("MathJax rendering failed:", e); } } else { window.MathJax = { loader: { load: ['[tex]/ams', '[tex]/boldsymbol'] }, options: { a11y: { inTabOrder: false } }, tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']], processEscapes: true, packages: { '[+]': ['ams', 'boldsymbol'] } } }; loadScript(CDN.mathjax).then(function() { if (context.renderId !== previewRenderGeneration) return; try { MathJax.typesetPromise(mathTargets).then(function() { if (context.renderId !== previewRenderGeneration) return; queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { mjx.removeAttribute('tabindex'); }); }).catch(function(err) { console.warn('MathJax typesetting failed:', err); }); } catch (e) { console.warn('MathJax rendering failed:', e); } }).catch(function(e) { console.warn('Failed to load MathJax:', e); }); } } updateDocumentStats(); updateFindHighlights(); cleanupImageObjectUrls(); scheduleLineNumberUpdate(); } function executeMainThreadRender(rawVal, context) { const { frontmatter, body } = parseFrontmatter(rawVal); const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : ''; const referenceData = extractReferenceDefinitions(body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = sanitizePreviewHtml(html); if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return; previewSegmentHtmlCache.clear(); const patchResult = commitPreviewHtml(sanitizedHtml, referenceData, rawVal, context); postProcessPreview(rawVal, context, patchResult); } function executeWorkerRender(rawVal, context) { requestPreviewWorkerRender(rawVal, context) .then(function(result) { if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return; if (!result || result.mode !== 'segmented' || !Array.isArray(result.blocks) || result.blocks.length < PREVIEW_SEGMENT_MIN_BLOCKS) { executeMainThreadRender(rawVal, Object.assign({}, context, { disableWorker: true })); return; } const segmentedHtml = buildSegmentedPreviewHtml(result.blocks, context.previewDocumentId); if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return; const patchResult = commitPreviewHtml(segmentedHtml, { definitions: new Map() }, rawVal, Object.assign({}, context, { previewEngineMode: 'segmented', })); postProcessPreview(rawVal, context, patchResult); }) .catch(function(error) { if (context.renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return; console.warn('Preview worker unavailable; falling back to main-thread renderer:', error); executeMainThreadRender(rawVal, Object.assign({}, context, { disableWorker: true })); }); } function renderMarkdown(options) { options = options || {}; const rawVal = markdownEditor.value; const force = options.force === true; const previewDocumentId = getActivePreviewDocumentId(); const hasCurrentPreview = previewHasCommittedRender && previewLastRenderedTabId === previewDocumentId && _lastRenderedContent === rawVal && !previewContainsSkeleton(); if (hasCurrentPreview && !force) return; clearPendingPreviewWork(); const renderId = ++previewRenderGeneration; const isLargeDocument = rawVal.length >= LARGE_DOCUMENT_THRESHOLD; const isDocumentSwap = previewHasCommittedRender && previewLastRenderedTabId !== previewDocumentId; const needsInitialPreview = !previewHasCommittedRender || previewContainsSkeleton(); const shouldShowSkeleton = isLargeDocument && (options.showSkeleton === true || needsInitialPreview || isDocumentSwap); if (shouldShowSkeleton) { showPreviewSkeleton(); } else if (markdownPreview) { markdownPreview.setAttribute('aria-busy', 'true'); markdownPreview.dataset.renderState = 'refreshing'; } const runRender = function() { pendingPreviewRenderCancel = null; if (renderId !== previewRenderGeneration || markdownEditor.value !== rawVal) return; executeRender(rawVal, { force, renderId, previewDocumentId, reason: options.reason || 'direct', }); }; if (isLargeDocument) { pendingPreviewRenderCancel = deferPreviewWork(runRender, rawVal.length); } else { runRender(); } } function executeRender(rawVal, context) { context = context || {}; // PERF-003: Skip render if content hasn't changed if ( !context.force && rawVal === _lastRenderedContent && previewLastRenderedTabId === context.previewDocumentId && previewHasCommittedRender && !previewContainsSkeleton() ) { markdownPreview.removeAttribute('aria-busy'); markdownPreview.dataset.renderState = 'ready'; return; } try { if (shouldUsePreviewWorker(rawVal, context)) { executeWorkerRender(rawVal, context); } else { executeMainThreadRender(rawVal, context); } } catch (e) { console.error("Markdown rendering failed:", e); const safeMessage = escapeHtml(e && e.message ? e.message : 'Unknown error'); const safeMarkdown = escapeHtml(rawVal); markdownPreview.removeAttribute('aria-busy'); markdownPreview.dataset.renderState = 'error'; if (!previewHasCommittedRender || previewContainsSkeleton()) { markdownPreview.innerHTML = `
    Error rendering markdown: ${safeMessage}
    ${safeMarkdown}
    `; } } } function importMarkdownFile(file) { if (file.size > 10 * 1024 * 1024) { alert('File is too large (maximum 10MB supported).'); return; } const reader = new FileReader(); reader.onload = function(e) { const text = e.target.result || ''; // Simple binary check: look for null bytes in the first 8KB const checkLength = Math.min(text.length, 8000); for (let i = 0; i < checkLength; i++) { if (text.charCodeAt(i) === 0) { alert('Cannot import: The selected file appears to be a binary file.'); return; } } newTab(text, file.name.replace(/\.md$/i, '')); }; reader.onerror = function() { alert('Failed to read the file. Please check permissions and try again.'); }; reader.readAsText(file); } function isMarkdownPath(path) { return /\.(md|markdown)$/i.test(path || ""); } const MAX_GITHUB_FILES_SHOWN = 30; const GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS = 800; let lastGitHubImportRequestAt = 0; const selectedGitHubImportPaths = new Set(); let availableGitHubImportPaths = []; function getFileName(path) { return (path || "").split("/").pop() || "document.md"; } function buildRawGitHubUrl(owner, repo, ref, filePath) { const encodedPath = filePath .split("/") .map((part) => encodeURIComponent(part)) .join("/"); return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(ref)}/${encodedPath}`; } async function fetchGitHubJson(url) { const now = Date.now(); const waitTime = GITHUB_IMPORT_MIN_REQUEST_INTERVAL_MS - (now - lastGitHubImportRequestAt); if (waitTime > 0) { await new Promise((resolve) => setTimeout(resolve, waitTime)); } lastGitHubImportRequestAt = Date.now(); const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } }); if (!response.ok) { throw new Error(`GitHub API request failed (${response.status})`); } return response.json(); } async function fetchTextContent(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch file (${response.status})`); } return response.text(); } function parseGitHubImportUrl(input) { let parsedUrl; try { parsedUrl = new URL((input || "").trim()); } catch (_) { return null; } const host = parsedUrl.hostname.replace(/^www\./, ""); const segments = parsedUrl.pathname.split("/").filter(Boolean); if (host === "raw.githubusercontent.com") { if (segments.length < 4) return null; const [owner, repo, ref, ...rest] = segments; const filePath = rest.join("/"); return { owner, repo, ref, type: "file", filePath }; } if (host !== "github.com" || segments.length < 2) return null; const owner = segments[0]; const repo = segments[1].replace(/\.git$/i, ""); if (segments.length === 2) { return { owner, repo, type: "repo" }; } const mode = segments[2]; if (mode === "blob" && segments.length >= 5) { return { owner, repo, type: "file", ref: segments[3], filePath: segments.slice(4).join("/") }; } if (mode === "tree" && segments.length >= 4) { return { owner, repo, type: "tree", ref: segments[3], basePath: segments.slice(4).join("/") }; } return { owner, repo, type: "repo" }; } async function getDefaultBranch(owner, repo) { const repoInfo = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`); return repoInfo.default_branch; } async function listMarkdownFiles(owner, repo, ref, basePath) { const treeResponse = await fetchGitHubJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(ref)}?recursive=1`); const normalizedBasePath = (basePath || "").replace(/^\/+|\/+$/g, ""); return (treeResponse.tree || []) .filter((entry) => entry.type === "blob" && isMarkdownPath(entry.path)) .filter((entry) => !normalizedBasePath || entry.path === normalizedBasePath || entry.path.startsWith(normalizedBasePath + "/")) .map((entry) => entry.path) .sort((a, b) => a.localeCompare(b)); } function buildMarkdownFileTree(paths) { const root = { folders: {}, files: [] }; (paths || []).forEach((path) => { const segments = (path || "").split("/").filter(Boolean); if (!segments.length) return; const fileName = segments.pop(); let node = root; segments.forEach((segment) => { if (!node.folders[segment]) { node.folders[segment] = { folders: {}, files: [] }; } node = node.folders[segment]; }); node.files.push({ name: fileName, path }); }); return root; } function updateGitHubImportSelectedCount() { if (!githubImportSelectedCount) return; const count = selectedGitHubImportPaths.size; githubImportSelectedCount.textContent = `${count} selected`; } function updateGitHubSelectAllButtonLabel() { if (!githubImportSelectAllBtn) return; const total = availableGitHubImportPaths.length; const allSelected = total > 0 && selectedGitHubImportPaths.size === total; githubImportSelectAllBtn.textContent = allSelected ? "Clear All" : "Select All"; } function syncGitHubSelectionToButtons() { if (!githubImportTree) return; Array.from(githubImportTree.querySelectorAll(".github-tree-file-btn")).forEach((btn) => { const isSelected = selectedGitHubImportPaths.has(btn.dataset.path); btn.classList.toggle("is-selected", isSelected); btn.setAttribute("aria-pressed", isSelected ? "true" : "false"); }); } function setGitHubSelectedPaths(paths) { selectedGitHubImportPaths.clear(); (paths || []).forEach((path) => selectedGitHubImportPaths.add(path)); updateGitHubImportSelectedCount(); syncGitHubSelectionToButtons(); updateGitHubSelectAllButtonLabel(); } function toggleGitHubSelectedPath(path) { if (!path) return; if (selectedGitHubImportPaths.has(path)) { selectedGitHubImportPaths.delete(path); } else { selectedGitHubImportPaths.add(path); } updateGitHubImportSelectedCount(); syncGitHubSelectionToButtons(); updateGitHubSelectAllButtonLabel(); } function renderGitHubImportTree(paths) { if (!githubImportTree || !githubImportFileSelect) return; githubImportTree.innerHTML = ""; const tree = buildMarkdownFileTree(paths); const createTreeBranch = function(node, parentPath) { const list = document.createElement("ul"); const folderNames = Object.keys(node.folders).sort((a, b) => a.localeCompare(b)); folderNames.forEach((folderName) => { const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName; const item = document.createElement("li"); const folderLabel = document.createElement("span"); folderLabel.className = "github-tree-folder-label"; folderLabel.textContent = `📁 ${folderName}`; item.appendChild(folderLabel); item.appendChild(createTreeBranch(node.folders[folderName], folderPath)); list.appendChild(item); }); node.files .sort((a, b) => a.path.localeCompare(b.path)) .forEach((file) => { const fileItem = document.createElement("li"); const fileButton = document.createElement("button"); fileButton.type = "button"; fileButton.className = "github-tree-file-btn"; fileButton.dataset.path = file.path; fileButton.setAttribute("aria-pressed", "false"); fileButton.textContent = `📄 ${file.name}`; fileButton.addEventListener("click", function() { toggleGitHubSelectedPath(file.path); }); fileItem.appendChild(fileButton); list.appendChild(fileItem); }); return list; }; githubImportTree.appendChild(createTreeBranch(tree, "")); syncGitHubSelectionToButtons(); } function setGitHubImportLoading(isLoading) { if (!githubImportSubmitBtn) return; if (isLoading) { githubImportSubmitBtn.dataset.loadingText = githubImportSubmitBtn.textContent; githubImportSubmitBtn.textContent = "Importing..."; } else if (githubImportSubmitBtn.dataset.loadingText) { githubImportSubmitBtn.textContent = githubImportSubmitBtn.dataset.loadingText; delete githubImportSubmitBtn.dataset.loadingText; } } function setGitHubImportMessage(message, options = {}) { if (!githubImportError) return; const { isError = true } = options; githubImportError.classList.toggle("is-info", !isError); if (!message) { githubImportError.textContent = ""; githubImportError.style.display = "none"; return; } githubImportError.textContent = message; githubImportError.style.display = "block"; } function resetGitHubImportModal() { if (!githubImportUrlInput || !githubImportFileSelect || !githubImportSubmitBtn) return; if (githubImportTitle) { githubImportTitle.textContent = "Import Markdown from GitHub"; } githubImportUrlInput.value = ""; githubImportUrlInput.style.display = "block"; githubImportUrlInput.disabled = false; githubImportFileSelect.innerHTML = ""; githubImportFileSelect.style.display = "none"; githubImportFileSelect.disabled = false; if (githubImportSelectionToolbar) { githubImportSelectionToolbar.style.display = "none"; } availableGitHubImportPaths = []; setGitHubSelectedPaths([]); if (githubImportTree) { githubImportTree.innerHTML = ""; githubImportTree.style.display = "none"; } githubImportSubmitBtn.dataset.step = "url"; delete githubImportSubmitBtn.dataset.owner; delete githubImportSubmitBtn.dataset.repo; delete githubImportSubmitBtn.dataset.ref; githubImportSubmitBtn.textContent = "Import"; setGitHubImportMessage(""); } function openGitHubImportModal() { if (!githubImportModal || !githubImportUrlInput || !githubImportSubmitBtn) return; resetGitHubImportModal(); openAppModal(githubImportModal, { focusTarget: githubImportUrlInput, onClose: closeGitHubImportModal }); } function closeGitHubImportModal() { if (!githubImportModal) return; closeAppModal(githubImportModal); resetGitHubImportModal(); } async function handleGitHubImportSubmit() { if (!githubImportSubmitBtn || !githubImportUrlInput || !githubImportFileSelect) return; const setGitHubImportDialogDisabled = (disabled) => { githubImportSubmitBtn.disabled = disabled; if (githubImportCancelBtn) { githubImportCancelBtn.disabled = disabled; } if (githubImportSelectAllBtn) { githubImportSelectAllBtn.disabled = disabled; } }; const step = githubImportSubmitBtn.dataset.step || "url"; if (step === "select") { const selectedPaths = Array.from(selectedGitHubImportPaths); const owner = githubImportSubmitBtn.dataset.owner; const repo = githubImportSubmitBtn.dataset.repo; const ref = githubImportSubmitBtn.dataset.ref; if (!owner || !repo || !ref || !selectedPaths.length) { setGitHubImportMessage("Please select at least one file to import."); return; } setGitHubImportLoading(true); setGitHubImportDialogDisabled(true); announceToScreenReader("Importing selected files from GitHub..."); try { for (const selectedPath of selectedPaths) { const markdown = await fetchTextContent(buildRawGitHubUrl(owner, repo, ref, selectedPath)); newTab(markdown, getFileName(selectedPath).replace(/\.(md|markdown)$/i, "")); } closeGitHubImportModal(); announceToScreenReader("Files imported successfully."); } catch (error) { console.error("GitHub import failed:", error); setGitHubImportMessage("GitHub import failed: " + error.message); announceToScreenReader("GitHub import failed."); } finally { setGitHubImportDialogDisabled(false); setGitHubImportLoading(false); } return; } const urlInput = githubImportUrlInput.value.trim(); if (!urlInput) { setGitHubImportMessage("Please enter a GitHub URL."); return; } const parsed = parseGitHubImportUrl(urlInput); if (!parsed || !parsed.owner || !parsed.repo) { setGitHubImportMessage("Please enter a valid GitHub URL."); return; } setGitHubImportMessage(""); setGitHubImportLoading(true); setGitHubImportDialogDisabled(true); try { if (parsed.type === "file") { if (!isMarkdownPath(parsed.filePath)) { throw new Error("The provided URL does not point to a Markdown file."); } announceToScreenReader("Fetching file from GitHub..."); const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, parsed.ref, parsed.filePath)); newTab(markdown, getFileName(parsed.filePath).replace(/\.(md|markdown)$/i, "")); closeGitHubImportModal(); announceToScreenReader("File imported successfully."); return; } // Accessibility dynamic live announcer const fetchingText = I18N_DICTS[activeLang].loadingFiles || "Fetching file tree..."; announceToScreenReader(fetchingText); // Render hierarchical visual skeleton tree while the list is loading if (githubImportTree) { renderGitHubImportTreeSkeleton(); githubImportTree.style.display = "block"; } const ref = parsed.ref || await getDefaultBranch(parsed.owner, parsed.repo); const files = await listMarkdownFiles(parsed.owner, parsed.repo, ref, parsed.basePath || ""); if (!files.length) { if (githubImportTree) { githubImportTree.innerHTML = ""; githubImportTree.style.display = "none"; } setGitHubImportMessage("No Markdown files were found at that GitHub location."); announceToScreenReader("Failed to locate Markdown files."); return; } const shownFiles = files.slice(0, MAX_GITHUB_FILES_SHOWN); if (files.length === 1) { const targetPath = files[0]; announceToScreenReader("Fetching file content..."); const markdown = await fetchTextContent(buildRawGitHubUrl(parsed.owner, parsed.repo, ref, targetPath)); newTab(markdown, getFileName(targetPath).replace(/\.(md|markdown)$/i, "")); closeGitHubImportModal(); announceToScreenReader("File imported successfully."); return; } githubImportFileSelect.innerHTML = ""; githubImportUrlInput.style.display = "none"; githubImportFileSelect.style.display = "none"; if (githubImportSelectionToolbar) { githubImportSelectionToolbar.style.display = "flex"; } if (githubImportTree) { githubImportTree.style.display = "block"; } shownFiles.forEach((filePath) => { const option = document.createElement("option"); option.value = filePath; option.textContent = filePath; githubImportFileSelect.appendChild(option); }); availableGitHubImportPaths = shownFiles.slice(); setGitHubSelectedPaths(shownFiles[0] ? [shownFiles[0]] : []); renderGitHubImportTree(shownFiles); // Announce load complete announceToScreenReader("GitHub files loaded. " + files.length + " files available in the tree."); if (files.length > MAX_GITHUB_FILES_SHOWN) { setGitHubImportMessage(`Showing first ${MAX_GITHUB_FILES_SHOWN} of ${files.length} Markdown files.`, { isError: false }); } else { setGitHubImportMessage(""); } if (githubImportTitle) { githubImportTitle.textContent = "Select Markdown file(s) to import"; } githubImportSubmitBtn.dataset.step = "select"; githubImportSubmitBtn.dataset.owner = parsed.owner; githubImportSubmitBtn.dataset.repo = parsed.repo; githubImportSubmitBtn.dataset.ref = ref; githubImportSubmitBtn.textContent = "Import Selected"; } catch (error) { console.error("GitHub import failed:", error); setGitHubImportMessage("GitHub import failed: " + error.message); announceToScreenReader("GitHub import failed."); if (githubImportTree) { githubImportTree.innerHTML = ""; githubImportTree.style.display = "none"; } } finally { setGitHubImportDialogDisabled(false); setGitHubImportLoading(false); } } function scheduleEmojiLookupRefresh() { if (emojiLookupLoaded || emojiRenderScheduled) return; emojiRenderScheduled = true; loadEmojiEntries() .then(() => { if (emojiUrlMap.size) { renderMarkdown({ force: true, reason: 'emoji-refresh' }); } }) .finally(() => { emojiRenderScheduled = false; }); } function processEmojis(element) { // Early exit if the raw text content has no colon characters (PERF-013) // This avoids the expensive TreeWalker DOM walk for documents without emoji shortcodes if (!element.textContent || !element.textContent.includes(':')) return; // PERF-002: Lazy-load JoyPixels on first use if (typeof joypixels === 'undefined') { Promise.all([ loadScript(CDN.joypixels), loadStyle(CDN.joypixels_css) ]).then(function() { processEmojis(element); }); return; } const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null, false ); const textNodes = []; let node; while ((node = walker.nextNode())) { let parent = node.parentNode; let isInCode = false; while (parent && parent !== element) { if (parent.tagName === 'PRE' || parent.tagName === 'CODE') { isInCode = true; break; } parent = parent.parentNode; } if (!isInCode && node.nodeValue.includes(':')) { textNodes.push(node); } } let needsEmojiLookup = false; textNodes.forEach(textNode => { const text = textNode.nodeValue; const emojiRegex = /:([\w+-]+):/g; let match; let lastIndex = 0; let hasEmoji = false; const fragment = document.createDocumentFragment(); while ((match = emojiRegex.exec(text)) !== null) { const shortcode = match[1]; const emoji = joypixels.shortnameToUnicode(`:${shortcode}:`); if (emoji !== `:${shortcode}:`) { // If conversion was successful hasEmoji = true; if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index))); } fragment.appendChild(document.createTextNode(emoji)); lastIndex = emojiRegex.lastIndex; } else { const emojiUrl = emojiUrlMap.get(shortcode); if (emojiUrl) { hasEmoji = true; if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index))); } const image = document.createElement('img'); image.className = 'emoji-inline'; image.src = emojiUrl; image.alt = `:${shortcode}:`; image.loading = 'lazy'; image.setAttribute('aria-label', `:${shortcode}:`); fragment.appendChild(image); lastIndex = emojiRegex.lastIndex; } else if (!emojiLookupLoaded) { needsEmojiLookup = true; } } } if (hasEmoji) { if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.substring(lastIndex))); } textNode.parentNode.replaceChild(fragment, textNode); } }); if (needsEmojiLookup) { scheduleEmojiLookupRefresh(); } } function debouncedRender() { clearTimeout(markdownRenderTimeout); const delay = getPreviewRenderDelay(markdownEditor.value); markdownRenderTimeout = setTimeout(function() { renderMarkdown({ reason: 'edit' }); }, delay); } function countWordsFast(text) { let count = 0; let inWord = false; for (let i = 0; i < text.length; i += 1) { const code = text.charCodeAt(i); if ( code === 32 || code === 9 || code === 10 || code === 13 || code === 12 || code === 11 || code === 160 ) { inWord = false; } else if (!inWord) { count += 1; inWord = true; } } return count; } function updateDocumentStats() { const text = markdownEditor.value; const charCount = text.length; charCountElement.textContent = charCount.toLocaleString(); const wordCount = countWordsFast(text); wordCountElement.textContent = wordCount.toLocaleString(); const readingTimeMinutes = Math.ceil(wordCount / 200); readingTimeElement.textContent = readingTimeMinutes; } function syncEditorToPreview() { if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling) return; isEditorScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); scrollSyncTimeout = requestAnimationFrame(function() { const editorScrollRange = markdownEditor.scrollHeight - markdownEditor.clientHeight; const editorScrollRatio = editorScrollRange > 0 ? markdownEditor.scrollTop / editorScrollRange : 0; const previewScrollPosition = (previewPane.scrollHeight - previewPane.clientHeight) * editorScrollRatio; if (!isNaN(previewScrollPosition) && isFinite(previewScrollPosition)) { previewPane.scrollTop = previewScrollPosition; } setTimeout(function() { isEditorScrolling = false; }, 50); }); } function syncPreviewToEditor() { if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return; isPreviewScrolling = true; if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); scrollSyncTimeout = requestAnimationFrame(function() { const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight; const previewScrollRatio = previewScrollRange > 0 ? previewPane.scrollTop / previewScrollRange : 0; const editorScrollPosition = (markdownEditor.scrollHeight - markdownEditor.clientHeight) * previewScrollRatio; if (!isNaN(editorScrollPosition) && isFinite(editorScrollPosition)) { markdownEditor.scrollTop = editorScrollPosition; syncEditorScrollOverlays(); } setTimeout(function() { isPreviewScrolling = false; }, 50); }); } function toggleSyncScrolling() { syncScrollingEnabled = !syncScrollingEnabled; if (syncScrollingEnabled) { toggleSyncButton.innerHTML = ' Sync Off'; toggleSyncButton.classList.add("sync-disabled"); toggleSyncButton.classList.remove("sync-enabled"); toggleSyncButton.classList.add("sync-active"); } else { toggleSyncButton.innerHTML = ' Sync On'; toggleSyncButton.classList.add("sync-enabled"); toggleSyncButton.classList.remove("sync-disabled"); toggleSyncButton.classList.remove("sync-active"); } saveGlobalState({ syncScrollingEnabled }); } // View Mode Functions - Story 1.1 & 1.2 function setViewMode(mode) { if (mode === currentViewMode) return; const previousMode = currentViewMode; currentViewMode = mode; // Update content container class contentContainer.classList.remove('view-editor-only', 'view-preview-only', 'view-split'); contentContainer.classList.add('view-' + (mode === 'editor' ? 'editor-only' : mode === 'preview' ? 'preview-only' : 'split')); // Update button active states (desktop) viewModeButtons.forEach(btn => { const btnMode = btn.getAttribute('data-view-mode'); if (btnMode === mode) { btn.classList.add('is-active'); btn.setAttribute('aria-pressed', 'true'); } else { btn.classList.remove('is-active'); btn.setAttribute('aria-pressed', 'false'); } }); // Story 1.4: Update mobile button active states mobileViewModeButtons.forEach(btn => { const btnMode = btn.getAttribute('data-mode'); if (btnMode === mode) { btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true'); } else { btn.classList.remove('active'); btn.setAttribute('aria-pressed', 'false'); } }); // Story 1.2: Show/hide sync toggle based on view mode updateSyncToggleVisibility(mode); // Story 1.3: Handle pane widths when switching modes if (mode === 'split') { // Restore preserved pane widths when entering split mode applyPaneWidths(); } else { // Reset inline pane widths when not in split mode resetPaneWidths(); } // Re-render markdown when switching to a view that includes preview if (mode === 'split' || mode === 'preview') { renderMarkdown({ reason: 'view-switch' }); } if (mode === 'split' || mode === 'editor') { refreshEditorWidth(); scheduleLineNumberUpdate({ force: true }); updateFindHighlights(); scheduleEditorOverlayScrollSync(); } } function resolveViewToggleMode(mode) { if ((mode === 'editor' || mode === 'preview') && currentViewMode === mode) { return 'split'; } return mode; } // Story 1.2: Update sync toggle visibility function updateSyncToggleVisibility(mode) { const isSplitView = mode === 'split'; // Desktop sync toggle if (toggleSyncButton) { toggleSyncButton.style.display = ''; toggleSyncButton.disabled = !isSplitView; toggleSyncButton.setAttribute('aria-disabled', String(!isSplitView)); toggleSyncButton.removeAttribute('aria-hidden'); } // Mobile sync toggle if (mobileToggleSync) { mobileToggleSync.style.display = ''; mobileToggleSync.disabled = !isSplitView; mobileToggleSync.setAttribute('aria-disabled', String(!isSplitView)); mobileToggleSync.removeAttribute('aria-hidden'); } } function replaceEditorRange(start, end, replacement, selectStart, selectEnd) { pushProgrammaticHistoryState(); markdownEditor.focus(); markdownEditor.setRangeText(replacement, start, end, 'end'); const nextStart = typeof selectStart === 'number' ? selectStart : start + replacement.length; const nextEnd = typeof selectEnd === 'number' ? selectEnd : nextStart; markdownEditor.setSelectionRange(nextStart, nextEnd); markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); lastPushedValue = markdownEditor.value; lastInputType = 'programmatic'; } function wrapEditorSelection(prefix, suffix, placeholder) { const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const selected = markdownEditor.value.slice(start, end) || placeholder; const replacement = prefix + selected + suffix; const selectionStart = start + prefix.length; const selectionEnd = selectionStart + selected.length; replaceEditorRange(start, end, replacement, selectionStart, selectionEnd); } function getCurrentLineRange() { const value = markdownEditor.value; const start = markdownEditor.selectionStart; const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; let lineEnd = value.indexOf('\n', start); if (lineEnd === -1) lineEnd = value.length; return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) }; } function getSelectedLineRange() { const value = markdownEditor.value; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; let lineEnd = value.indexOf('\n', end); if (lineEnd === -1) lineEnd = value.length; return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) }; } function transformEditorLines(transformer) { const range = getSelectedLineRange(); const replacement = range.text.split('\n').map(transformer).join('\n'); replaceEditorRange(range.start, range.end, replacement, range.start, range.start + replacement.length); } function getListLineRange() { const value = markdownEditor.value; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const effectiveEnd = end > start && value[end - 1] === '\n' ? end - 1 : end; const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; let lineEnd = value.indexOf('\n', effectiveEnd); if (lineEnd === -1) lineEnd = value.length; return { start: lineStart, end: lineEnd, text: value.slice(lineStart, lineEnd) }; } function parseMarkdownListItem(line) { const match = line.match(/^(\s*)((\d+)\.|[-*+])(?:\s+|$)(.*)$/); if (!match) return null; const isOrdered = typeof match[3] !== 'undefined'; return { type: isOrdered ? 'ordered' : 'unordered', indent: match[1], marker: match[2], number: isOrdered ? parseInt(match[3], 10) : null, bullet: isOrdered ? null : match[2], body: match[4] || '', prefix: match[1] + match[2] + ' ' }; } function stripListMarkerForApply(line) { const parsed = parseMarkdownListItem(line); if (parsed) { return { indent: parsed.indent, body: parsed.body }; } const match = line.match(/^(\s*)(.*)$/); return { indent: match ? match[1] : '', body: match ? match[2] : line }; } function getPreviousLineInfo(lineStart) { if (lineStart <= 0) return null; const value = markdownEditor.value; const previousEnd = lineStart - 1; const previousStart = previousEnd > 0 ? value.lastIndexOf('\n', previousEnd - 1) + 1 : 0; return { start: previousStart, text: value.slice(previousStart, previousEnd) }; } function getOrderedListStartNumber(lineStart) { const previousLine = getPreviousLineInfo(lineStart); if (!previousLine || !previousLine.text.trim()) return 1; const parsed = parseMarkdownListItem(previousLine.text); return parsed && parsed.type === 'ordered' ? parsed.number + 1 : 1; } function applyMarkdownList(type) { const range = getListLineRange(); const hadSelection = markdownEditor.selectionStart !== markdownEditor.selectionEnd; const lines = range.text.split('\n'); let nextNumber = type === 'ordered' ? getOrderedListStartNumber(range.start) : 1; let firstPrefixLength = null; const replacement = lines.map(function(line) { const stripped = stripListMarkerForApply(line); const prefix = type === 'ordered' ? stripped.indent + (nextNumber++) + '. ' : stripped.indent + '- '; if (firstPrefixLength === null) firstPrefixLength = prefix.length; return prefix + stripped.body; }).join('\n'); const isSingleLine = lines.length === 1; const caret = (!hadSelection || isSingleLine) ? range.start + (firstPrefixLength || 0) : range.start + replacement.length; replaceEditorRange(range.start, range.end, replacement, caret, caret); } function renumberOrderedListAfterPosition(position, nextNumber) { let value = markdownEditor.value; let lineStart = value.indexOf('\n', position); if (lineStart === -1) return; lineStart += 1; let changed = false; while (lineStart < value.length) { let lineEnd = value.indexOf('\n', lineStart); const hasNewline = lineEnd !== -1; if (!hasNewline) lineEnd = value.length; const line = value.slice(lineStart, lineEnd); if (!line.trim()) break; const parsed = parseMarkdownListItem(line); if (!parsed || parsed.type !== 'ordered') break; const replacement = parsed.indent + nextNumber + '. ' + parsed.body; if (replacement !== line) { value = value.slice(0, lineStart) + replacement + value.slice(lineEnd); changed = true; } lineStart += replacement.length + (hasNewline ? 1 : 0); nextNumber += 1; } if (changed) { const selectionStart = markdownEditor.selectionStart; const selectionEnd = markdownEditor.selectionEnd; markdownEditor.value = value; markdownEditor.setSelectionRange(selectionStart, selectionEnd); markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); } } function handleListEnter(e) { if (e.key !== 'Enter' || e.shiftKey || markdownEditor.selectionStart !== markdownEditor.selectionEnd) { return false; } const range = getCurrentLineRange(); const parsed = parseMarkdownListItem(range.text); if (!parsed) return false; e.preventDefault(); if (!parsed.body.trim()) { const caret = range.start + parsed.indent.length; replaceEditorRange(range.start, range.end, parsed.indent, caret, caret); return true; } const nextPrefix = parsed.type === 'ordered' ? parsed.indent + (parsed.number + 1) + '. ' : parsed.indent + parsed.bullet + ' '; const insertAt = markdownEditor.selectionStart; const caret = insertAt + 1 + nextPrefix.length; replaceEditorRange(insertAt, insertAt, '\n' + nextPrefix, caret, caret); if (parsed.type === 'ordered') { renumberOrderedListAfterPosition(caret, parsed.number + 2); } return true; } function transformSelectionOrCurrentLine(transformer) { let start = markdownEditor.selectionStart; let end = markdownEditor.selectionEnd; if (start === end) { const range = getCurrentLineRange(); start = range.start; end = range.end; } const replacement = transformer(markdownEditor.value.slice(start, end)); replaceEditorRange(start, end, replacement, start, start + replacement.length); } function stripBasicMarkdown(text) { return text .replace(/^#{1,6}\s+/gm, '') .replace(/^>\s?/gm, '') .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, '$1') .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/(\*\*|__)(.*?)\1/g, '$2') .replace(/(\*|_)(.*?)\1/g, '$2') .replace(/~~(.*?)~~/g, '$1') .replace(/`([^`]+)`/g, '$1'); } function getOrCreateTabHistory(tabId) { if (!tabId) return { undoStack: [], redoStack: [] }; if (!tabHistories[tabId]) { tabHistories[tabId] = { undoStack: [], redoStack: [] }; } return tabHistories[tabId]; } function initTabHistory(tabId, initialValue) { const hist = getOrCreateTabHistory(tabId); if (hist.undoStack.length === 0) { hist.undoStack.push({ value: initialValue || '', selectionStart: 0, selectionEnd: 0 }); lastPushedValue = initialValue || ''; currentHistoryTabId = tabId; pendingState = null; } } function pushProgrammaticHistoryState() { if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } const tabId = activeTabId; const hist = getOrCreateTabHistory(tabId); const currentValue = markdownEditor.value; if (pendingState) { hist.undoStack.push(pendingState); if (hist.undoStack.length > 200) { hist.undoStack.shift(); } hist.redoStack.length = 0; pendingState = null; lastPushedValue = currentValue; } else if (currentValue !== lastPushedValue) { hist.undoStack.push({ value: currentValue, selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd }); if (hist.undoStack.length > 200) { hist.undoStack.shift(); } hist.redoStack.length = 0; lastPushedValue = currentValue; } updateUndoRedoButtons(); } function commitPendingState() { if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } if (!pendingState) return; const tabId = activeTabId; const hist = getOrCreateTabHistory(tabId); hist.undoStack.push(pendingState); if (hist.undoStack.length > 200) { hist.undoStack.shift(); } hist.redoStack.length = 0; lastPushedValue = markdownEditor.value; pendingState = null; updateUndoRedoButtons(); } function handleKeystrokeHistory(e) { const currentValue = markdownEditor.value; if (currentValue === lastPushedValue) return; const inputType = e && typeof e.inputType === 'string' ? e.inputType : ''; if (!pendingState) { pendingState = { value: lastPushedValue, selectionStart: lastCursorStart, selectionEnd: lastCursorEnd }; } let shouldCommit = false; if (inputType === 'insertLineBreak' || inputType === 'insertParagraph' || inputType === 'insertFromPaste' || lastInputType === 'programmatic') { shouldCommit = true; } else if (e && e.data === ' ') { shouldCommit = true; } else { const isDelete = inputType.startsWith('delete'); const wasDelete = lastInputType === 'delete'; const isInsert = inputType.startsWith('insert'); const wasInsert = lastInputType === 'insert'; if ((isDelete && wasInsert) || (isInsert && wasDelete)) { shouldCommit = true; } } if (shouldCommit) { commitPendingState(); } if (typingTimeout) { clearTimeout(typingTimeout); } typingTimeout = setTimeout(function() { commitPendingState(); }, 1000); if (inputType.startsWith('delete')) { lastInputType = 'delete'; } else if (inputType.startsWith('insert')) { lastInputType = 'insert'; } else { lastInputType = 'other'; } } function updateLastCursor() { if (markdownEditor) { lastCursorStart = markdownEditor.selectionStart; lastCursorEnd = markdownEditor.selectionEnd; } } function updateUndoRedoButtons() { const undoBtn = document.querySelector('[data-md-action="undo"]'); const redoBtn = document.querySelector('[data-md-action="redo"]'); if (!undoBtn || !redoBtn) return; const tabId = activeTabId; const hist = getOrCreateTabHistory(tabId); const canUndo = hist.undoStack.length > 0 || pendingState !== null; const canRedo = hist.redoStack.length > 0; undoBtn.disabled = !canUndo; undoBtn.classList.toggle('disabled', !canUndo); redoBtn.disabled = !canRedo; redoBtn.classList.toggle('disabled', !canRedo); } function executeUndo() { if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } const tabId = activeTabId; const hist = getOrCreateTabHistory(tabId); const currentValue = markdownEditor.value; let stateToRestore = null; if (pendingState) { stateToRestore = pendingState; pendingState = null; hist.redoStack.push({ value: currentValue, selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd }); if (hist.redoStack.length > 200) { hist.redoStack.shift(); } } else if (hist.undoStack.length > 0) { const topState = hist.undoStack.pop(); if (topState) { stateToRestore = topState; hist.redoStack.push({ value: currentValue, selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd }); if (hist.redoStack.length > 200) { hist.redoStack.shift(); } } } if (stateToRestore) { markdownEditor.value = stateToRestore.value; markdownEditor.setSelectionRange(stateToRestore.selectionStart, stateToRestore.selectionEnd); lastPushedValue = stateToRestore.value; lastInputType = null; markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); saveCurrentTabState(); } updateUndoRedoButtons(); } function executeRedo() { if (typingTimeout) { clearTimeout(typingTimeout); typingTimeout = null; } const tabId = activeTabId; const hist = getOrCreateTabHistory(tabId); const currentValue = markdownEditor.value; if (hist.redoStack.length > 0) { const stateToRestore = hist.redoStack.pop(); hist.undoStack.push({ value: currentValue, selectionStart: markdownEditor.selectionStart, selectionEnd: markdownEditor.selectionEnd }); if (hist.undoStack.length > 200) { hist.undoStack.shift(); } markdownEditor.value = stateToRestore.value; markdownEditor.setSelectionRange(stateToRestore.selectionStart, stateToRestore.selectionEnd); lastPushedValue = stateToRestore.value; lastInputType = null; pendingState = null; markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); saveCurrentTabState(); } updateUndoRedoButtons(); } function stripMarkdownFormatting(text) { if (!text) return ''; return text // Remove fenced code block syntax .replace(/^```[a-zA-Z0-9-]*\r?\n?/gm, '') .replace(/```\r?$/gm, '') // Remove reference link definitions (e.g., [id]: url "title") .replace(/^\[[^\]]+\]:\s*\S+(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?\s*$/gm, '') // Strip basic markdown constructs (headers, blockquotes, lists, bold, italic, strikethrough, code) .replace(/^#{1,6}\s+/gm, '') .replace(/^>\s?/gm, '') .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, '$1') .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // HTML alignment tags or custom tags (strip the tags, keep inner text) .replace(/<[^>]+>/g, '') // Bold, Italic, Strikethrough, Inline code .replace(/(\*\*|__)(.*?)\1/g, '$2') .replace(/(\*|_)(.*?)\1/g, '$2') .replace(/~~(.*?)~~/g, '$1') .replace(/`([^`]+)`/g, '$1') // Remove horizontal rules .replace(/^\s*[-*_]{3,}\s*$/gm, ''); } function applyClearFormatting() { const fullText = markdownEditor.value; pushProgrammaticHistoryState(); replaceEditorRange(0, fullText.length, '', 0, 0); // Force immediate visual rendering and gutter update renderMarkdown(); updateLineNumbers(); updateFindHighlights(); saveCurrentTabState(); } function toTitleCase(text) { return text.toLowerCase().replace(/\b\w/g, function(letter) { return letter.toUpperCase(); }); } function toSlug(text) { const slug = text.toLowerCase().trim() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); return slug || 'section'; } function getUsedReferenceNumbers(text) { const used = new Set(); const regex = /^\[(\d+)\]:/gm; let match = regex.exec(text); while (match) { const num = parseInt(match[1], 10); if (!Number.isNaN(num)) used.add(num); match = regex.exec(text); } return used; } function extractReferenceDefinitions(markdown) { const definitions = new Map(); // Matches reference definitions: [1]: "title", [1]: url 'title', or [1]: url (title) const definitionRegex = /^\[(\d+)\]:\s*(?:<([^>\s]+)>|(\S+))(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm; const cleanedMarkdown = markdown.replace( definitionRegex, function(match, numberText, angleUrl, plainUrl, titleDouble, titleSingle, titleParen) { const number = parseInt(numberText, 10); if (Number.isNaN(number)) return match; const url = (angleUrl || plainUrl || '').trim(); if (!url) return match; const title = titleDouble || titleSingle || titleParen || ''; definitions.set(number, { url: url, title: title }); return ''; } ); return { definitions, cleanedMarkdown }; } function getNextAvailableReferenceNumber(used, startNumber) { let next = Math.max(1, startNumber || 1); while (used.has(next)) next += 1; return next; } function sanitizeMarkdownTitle(title) { return title .replace(/\\/g, '\\\\') .replace(/"/g, '\\"'); } function isSafeReferenceUrl(url) { if (!url) return false; try { const parsed = new URL(url, window.location.href); return ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol); } catch (e) { return false; } } function applyReferencePreviewLinks(container, referenceDefinitions) { if (!container || !referenceDefinitions || referenceDefinitions.size === 0) return; function applyReferenceStyle(link, number) { const definition = referenceDefinitions.get(number); if (definition && definition.url && isSafeReferenceUrl(definition.url)) { link.setAttribute('href', definition.url); if (definition.title) { link.setAttribute('title', definition.title); } else { link.removeAttribute('title'); } } else { link.removeAttribute('href'); } link.textContent = '[' + number + ']'; link.classList.add('reference-link'); } const links = container.querySelectorAll('a'); links.forEach(function(link) { const text = link.textContent.trim(); let number = null; if (/^\d+$/.test(text)) { number = parseInt(text, 10); } else { const match = text.match(/^\[(\d+)\]$/); if (match) number = parseInt(match[1], 10); } if (number && referenceDefinitions.has(number)) { applyReferenceStyle(link, number); } }); const referenceRegex = /\[(\d+)\](?!\s*:)/g; const nodesToProcess = []; const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null); while (walker.nextNode()) { const node = walker.currentNode; const parent = node.parentElement; if (!parent || !node.nodeValue) continue; if (parent.closest('a, code, pre, script, style, mjx-container')) continue; referenceRegex.lastIndex = 0; if (referenceRegex.test(node.nodeValue)) { nodesToProcess.push(node); } } nodesToProcess.forEach(function(node) { const text = node.nodeValue; referenceRegex.lastIndex = 0; let match; let lastIndex = 0; const fragment = document.createDocumentFragment(); while ((match = referenceRegex.exec(text)) !== null) { const before = text.slice(lastIndex, match.index); if (before) fragment.appendChild(document.createTextNode(before)); const number = parseInt(match[1], 10); const definition = referenceDefinitions.get(number); if (definition && definition.url && isSafeReferenceUrl(definition.url)) { const link = document.createElement('a'); link.href = definition.url; if (definition.title) link.title = definition.title; link.textContent = '[' + number + ']'; link.classList.add('reference-link'); fragment.appendChild(link); } else { fragment.appendChild(document.createTextNode(match[0])); } lastIndex = match.index + match[0].length; } const after = text.slice(lastIndex); if (after) fragment.appendChild(document.createTextNode(after)); node.parentNode.replaceChild(fragment, node); }); } function cleanupImageObjectUrls() { if (imageObjectUrls.size === 0) return; const contents = [markdownEditor.value]; if (Array.isArray(tabs)) { tabs.forEach(function(tab) { if (tab && typeof tab.content === 'string' && tab.content) { contents.push(tab.content); } }); } const snapshot = contents.join('\n'); Array.from(imageObjectUrls).forEach(function(url) { if (!snapshot.includes(url)) { URL.revokeObjectURL(url); imageObjectUrls.delete(url); } }); } function insertAlignmentBlock(align) { const allowedAlignments = new Set(['left', 'center', 'right']); const isAllowed = allowedAlignments.has(align); if (!isAllowed) { console.warn('Unsupported alignment:', align); return; } const safeAlign = align; const value = markdownEditor.value; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const selected = value.slice(start, end); const hasSelection = start !== end; const blockStart = `
    \n`; const blockEnd = `\n
    `; const block = `${blockStart}${hasSelection ? selected : ''}${blockEnd}`; const needsLeadingBreak = start > 0 && value[start - 1] !== '\n'; const needsTrailingBreak = end < value.length && value[end] !== '\n'; const replacement = (needsLeadingBreak ? '\n' : '') + block + (needsTrailingBreak ? '\n' : ''); const contentStart = start + (needsLeadingBreak ? 1 : 0) + blockStart.length; const contentEnd = contentStart + (hasSelection ? selected.length : 0); replaceEditorRange(start, end, replacement, contentStart, hasSelection ? contentEnd : contentStart); } function insertMarkdownBlock(block, startOverride, endOverride) { const value = markdownEditor.value; const start = typeof startOverride === 'number' ? startOverride : markdownEditor.selectionStart; const end = typeof endOverride === 'number' ? endOverride : markdownEditor.selectionEnd; const needsLeadingBreak = start > 0 && value[start - 1] !== '\n'; const needsTrailingBreak = end < value.length && value[end] !== '\n'; const replacement = (needsLeadingBreak ? '\n' : '') + block + (needsTrailingBreak ? '\n' : ''); const caret = start + replacement.length; replaceEditorRange(start, end, replacement, caret, caret); } function clampNumber(value, min, max, fallback) { const parsed = parseInt(value, 10); if (Number.isNaN(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); } function buildMarkdownTable(columns, rows) { const header = Array.from({ length: columns }, (_, index) => `Column ${index + 1}`).join(' | '); const divider = Array.from({ length: columns }, () => '---').join(' | '); const bodyRows = Array.from({ length: rows }, () => `| ${Array.from({ length: columns }, () => 'Value').join(' | ')} |`); return `| ${header} |\n| ${divider} |\n${bodyRows.join('\n')}\n`; } function loadEmojiEntries() { if (emojiLoadPromise) return emojiLoadPromise; emojiLoadPromise = fetch(EMOJI_API_URL) .then((response) => { if (!response.ok) throw new Error(`Emoji request failed (${response.status})`); return response.json(); }) .then((data) => { emojiEntries = Object.keys(data) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ name, url: data[name], shortcode: `:${name}:`, search: `${name} :${name}:`.toLowerCase(), })); emojiUrlMap = new Map(emojiEntries.map((entry) => [entry.name, entry.url])); emojiLookupLoaded = true; return emojiEntries; }) .catch((error) => { console.error('Failed to load GitHub emojis:', error); emojiEntries = []; emojiUrlMap = new Map(); emojiLookupLoaded = true; return emojiEntries; }); return emojiLoadPromise; } function createAlertPreview(type, meta) { const wrapper = document.createElement('div'); wrapper.className = `markdown-alert markdown-alert-${type}`; const title = document.createElement('p'); title.className = 'markdown-alert-title'; const icon = document.createElement('span'); icon.className = 'markdown-alert-icon'; icon.setAttribute('aria-hidden', 'true'); if (meta.path) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', meta.viewBox || '0 0 512 512'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', meta.path); svg.appendChild(path); icon.appendChild(svg); } const label = document.createElement('span'); label.textContent = meta.label; title.appendChild(icon); title.appendChild(label); const body = document.createElement('p'); body.textContent = `${meta.label} details go here.`; wrapper.appendChild(title); wrapper.appendChild(body); return wrapper; } function flashCopyButton(button) { const icon = button.querySelector('i'); if (!icon) return; icon.className = 'bi bi-check-lg'; button.classList.add('is-copied'); clearTimeout(button.copyTimeout); button.copyTimeout = setTimeout(() => { icon.className = 'bi bi-clipboard'; button.classList.remove('is-copied'); }, 1200); } function openTableModal() { const modal = document.getElementById('table-modal'); const columnInput = document.getElementById('table-modal-columns'); const rowInput = document.getElementById('table-modal-rows'); const confirmBtn = document.getElementById('table-modal-insert'); const cancelBtn = document.getElementById('table-modal-cancel'); if (!modal || !columnInput || !rowInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; columnInput.value = '3'; rowInput.value = '1'; modal.style.display = 'flex'; function insertTable() { const columns = clampNumber(columnInput.value, 1, 20, 3); const rows = clampNumber(rowInput.value, 1, 20, 1); const table = buildMarkdownTable(columns, rows); modal.style.display = 'none'; cleanup(); insertMarkdownBlock(table, start, end); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); insertTable(); } else if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', insertTable); cancelBtn.removeEventListener('click', closeModal); columnInput.removeEventListener('keydown', onKey); rowInput.removeEventListener('keydown', onKey); } confirmBtn.addEventListener('click', insertTable); cancelBtn.addEventListener('click', closeModal); columnInput.addEventListener('keydown', onKey); rowInput.addEventListener('keydown', onKey); requestAnimationFrame(() => { columnInput.focus(); columnInput.select(); }); } function openEmojiModal() { const modal = document.getElementById('emoji-modal'); const grid = document.getElementById('emoji-modal-grid'); const emptyMessage = document.getElementById('emoji-modal-empty'); const searchInput = document.getElementById('emoji-modal-search'); const confirmBtn = document.getElementById('emoji-modal-insert'); const cancelBtn = document.getElementById('emoji-modal-cancel'); if (!modal || !grid || !emptyMessage || !searchInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; modal.style.display = 'flex'; confirmBtn.disabled = true; // Localized and accessible announcements for loading const loadingText = I18N_DICTS[activeLang].loadingEmojis || "Loading emojis..."; emptyMessage.textContent = loadingText; emptyMessage.style.display = 'block'; announceToScreenReader(loadingText); searchInput.value = ''; emojiSelection.clear(); // PERF-007: Clear elements using textContent grid.textContent = ''; grid.scrollTop = 0; emojiItems = []; // Render visual placeholders during active requests if (!emojiLookupLoaded) { renderEmojiSkeletons(); } let currentFilteredEntries = []; let renderedCount = 0; const CHUNK_SIZE = 120; function updateInsertState() { confirmBtn.disabled = emojiSelection.size === 0; } function toggleSelection(shortcode, element) { if (emojiSelection.has(shortcode)) { emojiSelection.delete(shortcode); element.classList.remove('is-selected'); } else { emojiSelection.add(shortcode); element.classList.add('is-selected'); } element.setAttribute('aria-pressed', emojiSelection.has(shortcode).toString()); updateInsertState(); } function renderEmojiChunk(clear = false) { if (clear) { // PERF-007: Clear elements using textContent grid.textContent = ''; emojiItems = []; renderedCount = 0; } const nextBatch = currentFilteredEntries.slice(renderedCount, renderedCount + CHUNK_SIZE); if (nextBatch.length === 0) { emptyMessage.style.display = emojiItems.length ? 'none' : 'block'; return; } const fragment = document.createDocumentFragment(); const newItems = nextBatch.map((entry) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'emoji-item'; const isSelected = emojiSelection.has(entry.shortcode); if (isSelected) { item.classList.add('is-selected'); } item.setAttribute('aria-pressed', isSelected ? 'true' : 'false'); item.dataset.search = entry.search; item.dataset.shortcode = entry.shortcode; const preview = document.createElement('span'); preview.className = 'emoji-preview'; const image = document.createElement('img'); image.src = entry.url; image.alt = entry.shortcode; image.loading = 'lazy'; preview.appendChild(image); const shortcodeRow = document.createElement('div'); shortcodeRow.className = 'emoji-shortcode'; const code = document.createElement('span'); code.textContent = entry.shortcode; const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'emoji-copy-btn'; copyBtn.setAttribute('aria-label', `Copy ${entry.shortcode}`); copyBtn.innerHTML = ''; copyBtn.addEventListener('click', (event) => { event.stopPropagation(); copyTextToClipboard(entry.shortcode) .then(() => flashCopyButton(copyBtn)) .catch((error) => console.error('Copy failed:', error)); }); shortcodeRow.appendChild(code); shortcodeRow.appendChild(copyBtn); item.appendChild(preview); item.appendChild(shortcodeRow); item.addEventListener('click', () => toggleSelection(entry.shortcode, item)); fragment.appendChild(item); return { element: item, search: entry.search, shortcode: entry.shortcode }; }); emojiItems = emojiItems.concat(newItems); grid.appendChild(fragment); renderedCount += nextBatch.length; emptyMessage.style.display = emojiItems.length ? 'none' : 'block'; } function applyFilter() { const query = searchInput.value.trim().toLowerCase(); currentFilteredEntries = emojiEntries.filter(entry => !query || entry.search.includes(query)); renderEmojiChunk(true); } function handleScroll() { if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 60) { renderEmojiChunk(false); } } function insertEmojis() { if (!emojiSelection.size) return; // Retain the modal's visual/definition order of emojis, consistent with the Symbols modal const ordered = emojiItems .filter(item => emojiSelection.has(item.shortcode)) .map(item => item.shortcode); const insertion = ordered.join(' '); modal.style.display = 'none'; cleanup(); replaceEditorRange(start, end, insertion, start + insertion.length, start + insertion.length); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', insertEmojis); cancelBtn.removeEventListener('click', closeModal); searchInput.removeEventListener('input', applyFilter); searchInput.removeEventListener('keydown', onKey); grid.removeEventListener('scroll', handleScroll); } loadEmojiEntries().then((entries) => { if (!entries.length) { emptyMessage.textContent = 'Unable to load emojis.'; emptyMessage.style.display = 'block'; // PERF-007: Clear elements using textContent grid.textContent = ''; emojiItems = []; announceToScreenReader("Failed to load emojis."); return; } currentFilteredEntries = entries; renderEmojiChunk(true); emptyMessage.textContent = 'No emojis found.'; announceToScreenReader("Emojis loaded. " + entries.length + " items available."); updateInsertState(); }); confirmBtn.addEventListener('click', insertEmojis); cancelBtn.addEventListener('click', closeModal); searchInput.addEventListener('input', applyFilter); searchInput.addEventListener('keydown', onKey); grid.addEventListener('scroll', handleScroll); requestAnimationFrame(() => searchInput.focus()); } function openSymbolsModal() { const modal = document.getElementById('symbols-modal'); const grid = document.getElementById('symbols-modal-grid'); const emptyMessage = document.getElementById('symbols-modal-empty'); const searchInput = document.getElementById('symbols-modal-search'); const confirmBtn = document.getElementById('symbols-modal-insert'); const cancelBtn = document.getElementById('symbols-modal-cancel'); if (!modal || !grid || !emptyMessage || !searchInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; modal.style.display = 'flex'; confirmBtn.disabled = true; searchInput.value = ''; symbolSelection.clear(); // PERF-007: Clear elements using textContent grid.textContent = ''; const sectionEntries = []; SYMBOL_SECTIONS.forEach((section) => { const sectionWrapper = document.createElement('div'); sectionWrapper.className = 'symbol-section'; const title = document.createElement('p'); title.className = 'symbol-section-title'; title.textContent = section.title; const sectionGrid = document.createElement('div'); sectionGrid.className = 'symbol-section-grid'; const sectionItems = section.items.map((entry) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'symbol-item'; item.setAttribute('aria-pressed', 'false'); const preview = document.createElement('span'); preview.className = 'symbol-preview'; preview.textContent = entry.symbol; const codeRow = document.createElement('div'); codeRow.className = 'symbol-code'; const code = document.createElement('span'); code.textContent = entry.entity; const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'symbol-copy-btn'; copyBtn.setAttribute('aria-label', `Copy ${entry.entity}`); copyBtn.innerHTML = ''; copyBtn.addEventListener('click', (event) => { event.stopPropagation(); copyTextToClipboard(entry.entity) .then(() => flashCopyButton(copyBtn)) .catch((error) => console.error('Copy failed:', error)); }); codeRow.appendChild(code); codeRow.appendChild(copyBtn); item.appendChild(preview); item.appendChild(codeRow); item.dataset.search = `${entry.symbol} ${entry.entity} ${entry.name}`.toLowerCase(); item.dataset.entity = entry.entity; item.addEventListener('click', () => { if (symbolSelection.has(entry.entity)) { symbolSelection.delete(entry.entity); item.classList.remove('is-selected'); } else { symbolSelection.add(entry.entity); item.classList.add('is-selected'); } item.setAttribute('aria-pressed', symbolSelection.has(entry.entity).toString()); confirmBtn.disabled = symbolSelection.size === 0; }); sectionGrid.appendChild(item); return { element: item, search: item.dataset.search, entity: entry.entity }; }); sectionWrapper.appendChild(title); sectionWrapper.appendChild(sectionGrid); grid.appendChild(sectionWrapper); sectionEntries.push({ wrapper: sectionWrapper, items: sectionItems }); }); symbolItems = sectionEntries.flatMap((section) => section.items); function applyFilter() { const query = searchInput.value.trim().toLowerCase(); let visibleCount = 0; sectionEntries.forEach((section) => { let sectionVisible = 0; section.items.forEach((item) => { const match = !query || item.search.includes(query); item.element.style.display = match ? '' : 'none'; if (match) { visibleCount += 1; sectionVisible += 1; } }); section.wrapper.style.display = sectionVisible ? '' : 'none'; }); emptyMessage.style.display = visibleCount ? 'none' : 'block'; } function insertSymbols() { if (!symbolSelection.size) return; const ordered = symbolItems .filter((item) => symbolSelection.has(item.entity)) .map((item) => item.entity); const insertion = ordered.join(' '); modal.style.display = 'none'; cleanup(); replaceEditorRange(start, end, insertion, start + insertion.length, start + insertion.length); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', insertSymbols); cancelBtn.removeEventListener('click', closeModal); searchInput.removeEventListener('input', applyFilter); searchInput.removeEventListener('keydown', onKey); } emptyMessage.textContent = 'No symbols found.'; applyFilter(); confirmBtn.addEventListener('click', insertSymbols); cancelBtn.addEventListener('click', closeModal); searchInput.addEventListener('input', applyFilter); searchInput.addEventListener('keydown', onKey); requestAnimationFrame(() => searchInput.focus()); } function openAlertModal() { const modal = document.getElementById('alert-modal'); const grid = document.getElementById('alert-modal-grid'); const confirmBtn = document.getElementById('alert-modal-insert'); const cancelBtn = document.getElementById('alert-modal-cancel'); if (!modal || !grid || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; modal.style.display = 'flex'; // PERF-007: Clear elements using textContent grid.textContent = ''; const alertTypes = ['note', 'tip', 'important', 'warning', 'caution']; let selectedType = alertTypes[0]; const options = []; alertTypes.forEach((type) => { const meta = GITHUB_ALERT_META[type] || { label: type }; const option = document.createElement('button'); option.type = 'button'; option.className = 'alert-option'; option.dataset.alertType = type; option.setAttribute('aria-pressed', (type === selectedType).toString()); const preview = document.createElement('div'); preview.className = 'alert-preview'; preview.appendChild(createAlertPreview(type, meta)); option.appendChild(preview); if (type === selectedType) option.classList.add('is-selected'); option.addEventListener('click', () => { selectedType = type; options.forEach((item) => { const isSelected = item === option; item.classList.toggle('is-selected', isSelected); item.setAttribute('aria-pressed', isSelected.toString()); }); }); options.push(option); grid.appendChild(option); }); function insertAlert() { const type = selectedType.toUpperCase(); const meta = GITHUB_ALERT_META[selectedType] || { label: selectedType }; const body = `${meta.label} details go here.`; const block = `> [!${type}]\n> ${body}\n`; modal.style.display = 'none'; cleanup(); insertMarkdownBlock(block, start, end); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', insertAlert); cancelBtn.removeEventListener('click', closeModal); modal.removeEventListener('keydown', onKey); } confirmBtn.addEventListener('click', insertAlert); cancelBtn.addEventListener('click', closeModal); modal.addEventListener('keydown', onKey); } function insertMarkdownLink() { const modal = document.getElementById('link-modal'); const urlInput = document.getElementById('link-modal-url'); const textInput = document.getElementById('link-modal-text'); const confirmBtn = document.getElementById('link-modal-apply'); const cancelBtn = document.getElementById('link-modal-cancel'); if (!modal || !urlInput || !textInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const selected = markdownEditor.value.slice(start, end); urlInput.value = 'https://'; textInput.value = selected || ''; modal.style.display = 'flex'; function applyLink() { const url = urlInput.value.trim() || 'https://'; const linkText = textInput.value.trim() || selected || 'link text'; const replacement = '[' + linkText + '](' + url + ')'; modal.style.display = 'none'; cleanup(); replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); applyLink(); } else if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', applyLink); cancelBtn.removeEventListener('click', closeModal); urlInput.removeEventListener('keydown', onKey); textInput.removeEventListener('keydown', onKey); } confirmBtn.addEventListener('click', applyLink); cancelBtn.addEventListener('click', closeModal); urlInput.addEventListener('keydown', onKey); textInput.addEventListener('keydown', onKey); requestAnimationFrame(function() { urlInput.focus(); urlInput.select(); }); } function insertMarkdownImage() { const modal = document.getElementById('image-modal'); const uploadOption = document.getElementById('image-source-upload'); const urlOption = document.getElementById('image-source-url'); const uploadFields = document.getElementById('image-upload-fields'); const urlFields = document.getElementById('image-url-fields'); const fileInput = document.getElementById('image-modal-file'); const urlInput = document.getElementById('image-modal-url'); const altInput = document.getElementById('image-modal-alt'); const confirmBtn = document.getElementById('image-modal-insert'); const cancelBtn = document.getElementById('image-modal-cancel'); if (!modal || !uploadOption || !urlOption || !uploadFields || !urlFields || !fileInput || !urlInput || !altInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const selected = markdownEditor.value.slice(start, end); urlInput.value = 'https://'; altInput.value = selected || ''; fileInput.value = ''; urlOption.checked = true; uploadOption.checked = false; modal.style.display = 'flex'; function buildImageMarkdown(url) { const titleText = altInput.value.trim(); const altText = titleText || 'alt text'; const safeTitle = sanitizeMarkdownTitle(titleText); const titlePart = safeTitle ? ' "' + safeTitle + '"' : ''; return '![' + altText + '](' + url + titlePart + ')'; } function insertImage(url) { const safeUrl = url.trim() || 'https://'; const replacement = buildImageMarkdown(safeUrl); modal.style.display = 'none'; cleanup(); replaceEditorRange(start, end, replacement, start + replacement.length, start + replacement.length); } function insertFromFile(file) { const objectUrl = URL.createObjectURL(file); imageObjectUrls.add(objectUrl); insertImage(objectUrl); } function updateMode(shouldFocus) { const isUpload = uploadOption.checked; uploadFields.style.display = isUpload ? 'flex' : 'none'; urlFields.style.display = isUpload ? 'none' : 'flex'; if (shouldFocus) { requestAnimationFrame(function() { if (isUpload) { fileInput.focus(); } else { urlInput.focus(); urlInput.select(); } }); } } function onModeChange() { updateMode(true); } function onFileChange() { const file = fileInput.files && fileInput.files[0]; if (file) { insertFromFile(file); } } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); if (uploadOption.checked) { const file = fileInput.files && fileInput.files[0]; if (file) insertFromFile(file); else fileInput.click(); } else { insertImage(urlInput.value); } } else if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function closeModal() { modal.style.display = 'none'; cleanup(); } function cleanup() { confirmBtn.removeEventListener('click', onConfirm); cancelBtn.removeEventListener('click', closeModal); uploadOption.removeEventListener('change', onModeChange); urlOption.removeEventListener('change', onModeChange); fileInput.removeEventListener('change', onFileChange); fileInput.removeEventListener('keydown', onKey); urlInput.removeEventListener('keydown', onKey); altInput.removeEventListener('keydown', onKey); } function onConfirm() { if (uploadOption.checked) { const file = fileInput.files && fileInput.files[0]; if (file) insertFromFile(file); else fileInput.click(); } else { insertImage(urlInput.value); } } confirmBtn.addEventListener('click', onConfirm); cancelBtn.addEventListener('click', closeModal); uploadOption.addEventListener('change', onModeChange); urlOption.addEventListener('change', onModeChange); fileInput.addEventListener('change', onFileChange); fileInput.addEventListener('keydown', onKey); urlInput.addEventListener('keydown', onKey); altInput.addEventListener('keydown', onKey); updateMode(true); } function insertMarkdownReference() { const modal = document.getElementById('reference-modal'); const numberInput = document.getElementById('reference-modal-number'); const urlInput = document.getElementById('reference-modal-url'); const titleInput = document.getElementById('reference-modal-title-input'); const confirmBtn = document.getElementById('reference-modal-apply'); const cancelBtn = document.getElementById('reference-modal-cancel'); if (!modal || !numberInput || !urlInput || !titleInput || !confirmBtn || !cancelBtn) return; const start = markdownEditor.selectionStart; const end = markdownEditor.selectionEnd; const currentValue = markdownEditor.value; const used = getUsedReferenceNumbers(currentValue); const maxUsed = used.size ? Math.max(...used) : 0; referenceCounter = Math.max(1, maxUsed + 1); const suggestedNumber = getNextAvailableReferenceNumber(used, referenceCounter); numberInput.value = '[' + suggestedNumber + ']'; urlInput.value = 'https://'; titleInput.value = ''; modal.style.display = 'flex'; function insertReference() { const latestValue = markdownEditor.value; const usedNumbers = getUsedReferenceNumbers(latestValue); const parsed = parseInt(numberInput.value.replace(/[^\d]/g, ''), 10); const baseNumber = Number.isNaN(parsed) ? suggestedNumber : parsed; const finalNumber = getNextAvailableReferenceNumber(usedNumbers, baseNumber); const url = urlInput.value.trim() || 'https://'; const title = titleInput.value.trim(); const safeTitle = sanitizeMarkdownTitle(title); const definition = '[' + finalNumber + ']: ' + url + (safeTitle ? ' "' + safeTitle + '"' : ''); const selected = latestValue.slice(start, end); const inlineReference = selected + '[' + finalNumber + ']'; const baseValue = latestValue.slice(0, start) + inlineReference + latestValue.slice(end); let separator = ''; if (baseValue.length && !baseValue.endsWith('\n')) { separator = '\n'; } const updatedValue = baseValue + separator + definition; markdownEditor.value = updatedValue; markdownEditor.focus(); const caret = start + inlineReference.length; markdownEditor.setSelectionRange(caret, caret); markdownEditor.dispatchEvent(new Event('input', { bubbles: true })); referenceCounter = Math.max(referenceCounter, finalNumber + 1); modal.style.display = 'none'; cleanup(); } function closeModal() { modal.style.display = 'none'; cleanup(); } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); insertReference(); } else if (e.key === 'Escape') { e.preventDefault(); closeModal(); } } function cleanup() { confirmBtn.removeEventListener('click', insertReference); cancelBtn.removeEventListener('click', closeModal); numberInput.removeEventListener('keydown', onKey); urlInput.removeEventListener('keydown', onKey); titleInput.removeEventListener('keydown', onKey); } confirmBtn.addEventListener('click', insertReference); cancelBtn.addEventListener('click', closeModal); numberInput.addEventListener('keydown', onKey); urlInput.addEventListener('keydown', onKey); titleInput.addEventListener('keydown', onKey); requestAnimationFrame(function() { numberInput.focus(); numberInput.select(); }); } function escapeRegExp(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getFocusableElements(container) { return Array.from(container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')) .filter(element => !element.disabled && element.offsetParent !== null); } function trapFocusInModal(modal, event) { const focusable = getFocusableElements(modal); if (!focusable.length) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } function openAppModal(modal, options = {}) { if (!modal) return; if (activeModal && activeModal !== modal) { closeAppModal(activeModal); } lastFocusedElement = document.activeElement; modal.style.display = 'flex'; requestAnimationFrame(function() { modal.classList.add('is-visible'); }); modal.setAttribute('aria-hidden', 'false'); activeModal = modal; const focusTarget = options.focusTarget || getFocusableElements(modal)[0]; if (focusTarget) { focusTarget.focus(); } const handleKeydown = function(event) { if (event.key === 'Escape') { event.preventDefault(); if (options.onClose) { options.onClose(); } else { closeAppModal(modal); } } else if (event.key === 'Tab') { trapFocusInModal(modal, event); } }; const handlePointerDown = function(event) { if (event.target === modal) { if (options.onClose) { options.onClose(); } else { closeAppModal(modal); } } }; modal.addEventListener('keydown', handleKeydown); modal.addEventListener('mousedown', handlePointerDown); modal._modalHandlers = { handleKeydown, handlePointerDown }; } function closeAppModal(modal) { if (!modal) return; modal.classList.remove('is-visible'); modal.setAttribute('aria-hidden', 'true'); const handlers = modal._modalHandlers || {}; if (handlers.handleKeydown) modal.removeEventListener('keydown', handlers.handleKeydown); if (handlers.handlePointerDown) modal.removeEventListener('mousedown', handlers.handlePointerDown); if (activeModal === modal) activeModal = null; window.setTimeout(function() { if (!modal.classList.contains('is-visible')) { modal.style.display = 'none'; } }, 200); if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') { lastFocusedElement.focus(); } } function updateFindHighlights() { updatePreviewFindHighlights(); if (!editorHighlightLayer) return; if (!isEditorVisible()) return; if (!isFindModalOpen || !findReplaceInput || !findReplaceInput.value || !findMatches.length) { if (editorHighlightLayer.textContent !== '') { editorHighlightLayer.textContent = ''; } return; } const text = markdownEditor.value || ''; const scrollTop = cachedScrollTop; const scrollLeft = cachedScrollLeft; const fragment = document.createDocumentFragment(); let lastIndex = 0; findMatches.forEach(function(match, index) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.start))); const mark = document.createElement('mark'); mark.className = 'find-highlight' + (index === activeFindIndex ? ' active' : ''); mark.textContent = text.slice(match.start, match.end); fragment.appendChild(mark); lastIndex = match.end; }); fragment.appendChild(document.createTextNode(text.slice(lastIndex))); editorHighlightLayer.textContent = ''; editorHighlightLayer.appendChild(fragment); editorHighlightLayer.scrollTop = scrollTop; editorHighlightLayer.scrollLeft = scrollLeft; } let previewHighlights = []; let activePreviewHighlightIndex = -1; function isPreviewVisible() { return currentViewMode === 'preview' || currentViewMode === 'split'; } function clearPreviewFindHighlights() { if (!markdownPreview) return; const highlights = markdownPreview.querySelectorAll('.preview-find-highlight'); highlights.forEach(function(el) { const parent = el.parentNode; if (parent) { parent.replaceChild(document.createTextNode(el.textContent), el); } }); markdownPreview.normalize(); } function highlightPreviewText(node, regex) { if (!node) return; if (node.nodeType === Node.TEXT_NODE) { const val = node.nodeValue; if (!val) return; regex.lastIndex = 0; let match; const matches = []; while ((match = regex.exec(val)) !== null) { if (match[0].length === 0) { regex.lastIndex++; continue; } matches.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); } if (matches.length > 0) { const parent = node.parentNode; if (!parent) return; const fragment = document.createDocumentFragment(); let lastIdx = 0; matches.forEach(function(m) { if (m.start > lastIdx) { fragment.appendChild(document.createTextNode(val.slice(lastIdx, m.start))); } const mark = document.createElement('mark'); mark.className = 'preview-find-highlight'; mark.textContent = m.text; fragment.appendChild(mark); lastIdx = m.end; }); if (lastIdx < val.length) { fragment.appendChild(document.createTextNode(val.slice(lastIdx))); } parent.replaceChild(fragment, node); } } else if (node.nodeType === Node.ELEMENT_NODE) { const tagName = node.tagName.toLowerCase(); if (tagName === 'script' || tagName === 'style' || tagName === 'textarea' || tagName === 'noscript' || tagName === 'svg') { return; } if (node.classList.contains('mermaid') || node.classList.contains('mjx-container') || node.closest('.mermaid') || node.closest('.mjx-container')) { return; } const children = Array.from(node.childNodes); children.forEach(function(child) { highlightPreviewText(child, regex); }); } } function updatePreviewFindHighlights() { clearPreviewFindHighlights(); previewHighlights = []; if (!isFindModalOpen || !findReplaceInput || !findReplaceInput.value || !isPreviewVisible()) { return; } const query = findReplaceInput.value; const isRegex = document.getElementById('find-regex').classList.contains('active'); const isCaseSensitive = document.getElementById('find-case').classList.contains('active'); const isWholeWord = document.getElementById('find-word').classList.contains('active'); let regex; try { let pattern = isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (isWholeWord) { pattern = `\\b${pattern}\\b`; } const flags = isCaseSensitive ? 'g' : 'gi'; regex = new RegExp(pattern, flags); } catch (e) { return; } highlightPreviewText(markdownPreview, regex); previewHighlights = Array.from(markdownPreview.querySelectorAll('.preview-find-highlight')); updateActivePreviewHighlight(); } function updateActivePreviewHighlight() { previewHighlights.forEach(function(el) { el.classList.remove('active'); }); if (!previewHighlights.length) { activePreviewHighlightIndex = -1; return; } if (findMatches.length > 0 && activeFindIndex >= 0) { const ratio = activeFindIndex / findMatches.length; activePreviewHighlightIndex = Math.min( previewHighlights.length - 1, Math.floor(ratio * previewHighlights.length) ); } else { activePreviewHighlightIndex = 0; } if (activePreviewHighlightIndex >= 0 && activePreviewHighlightIndex < previewHighlights.length) { const activeEl = previewHighlights[activePreviewHighlightIndex]; activeEl.classList.add('active'); scrollPreviewHighlightIntoView(activeEl); } } function scrollPreviewHighlightIntoView(element) { if (!element || !previewPane) return; const paneRect = previewPane.getBoundingClientRect(); const elemRect = element.getBoundingClientRect(); const isVisible = ( elemRect.top >= paneRect.top + 40 && elemRect.bottom <= paneRect.bottom - 40 ); if (!isVisible) { const scrollTop = previewPane.scrollTop + (elemRect.top - paneRect.top) - (paneRect.height / 2) + (elemRect.height / 2); previewPane.scrollTop = scrollTop; } } function syncHighlightScroll() { if (!editorHighlightLayer) return; editorHighlightLayer.scrollTop = cachedScrollTop; editorHighlightLayer.scrollLeft = cachedScrollLeft; } function scheduleEditorOverlayScrollSync() { if (editorOverlayScrollFrame) return; editorOverlayScrollFrame = requestAnimationFrame(function() { editorOverlayScrollFrame = null; syncHighlightScroll(); syncLineNumberScroll(); }); } function updateLineNumberGutter(lineCount) { if (!editorPaneElement) return; const digits = String(Math.max(1, lineCount)).length; const gutterSize = `${Math.max(LINE_NUMBER_GUTTER_MIN_CH, digits + LINE_NUMBER_GUTTER_PADDING_CH)}ch`; editorPaneElement.style.setProperty('--line-number-gutter', gutterSize); } function getLineHeight(styles) { const computed = parseFloat(styles.lineHeight); if (!Number.isNaN(computed)) return computed; const fontSize = parseFloat(styles.fontSize) || 14; return fontSize * 1.5; } function getWrappedLineCountMonospace(lineText, maxCharsPerLine) { if (!lineText) return 1; const words = lineText.replace(/\t/g, ' ').split(' '); let linesCount = 1; let currentLineLength = 0; for (let i = 0; i < words.length; i++) { const word = words[i]; const wordLength = word.length; if (wordLength === 0) { if (currentLineLength + 1 > maxCharsPerLine) { linesCount++; currentLineLength = 1; } else { currentLineLength++; } continue; } if (wordLength > maxCharsPerLine) { const remainingSpace = maxCharsPerLine - currentLineLength; if (remainingSpace > 0 && currentLineLength > 0) { const firstPart = wordLength - remainingSpace; linesCount += 1 + Math.floor(firstPart / maxCharsPerLine); currentLineLength = firstPart % maxCharsPerLine; } else { linesCount += Math.floor(wordLength / maxCharsPerLine); currentLineLength = wordLength % maxCharsPerLine; } continue; } const spaceRequired = currentLineLength === 0 ? 0 : 1; if (currentLineLength + spaceRequired + wordLength > maxCharsPerLine) { linesCount++; currentLineLength = wordLength; } else { currentLineLength += spaceRequired + wordLength; } } return Math.max(1, linesCount); } const lineCache = new Map(); let cachedPaddingLeft = 10; let cachedPaddingRight = 10; let cachedCharWidth = 0; let cachedLineHeight = 21; let cachedEditorWidth = 0; let cachedMaxCharsPerLine = 80; let cachedScrollTop = 0; let cachedScrollLeft = 0; let isGeometryInitialized = false; let lastLineNumberLineCount = 0; let lastLineNumberDocumentLength = 0; function countLinesFast(text) { if (!text) return 1; let count = 1; for (let i = 0; i < text.length; i += 1) { if (text.charCodeAt(i) === 10) count += 1; } return count; } function countLinesBeforeIndex(text, endIndex) { let count = 0; const max = Math.max(0, Math.min(text.length, endIndex)); for (let i = 0; i < max; i += 1) { if (text.charCodeAt(i) === 10) count += 1; } return count; } function initEditorGeometry() { if (!markdownEditor) return; const styles = window.getComputedStyle(markdownEditor); cachedPaddingLeft = parseFloat(styles.paddingLeft) || 10; cachedPaddingRight = parseFloat(styles.paddingRight) || 10; // Measure character width const testSpan = document.createElement('span'); testSpan.style.fontFamily = styles.fontFamily; testSpan.style.fontSize = styles.fontSize; testSpan.style.visibility = 'hidden'; testSpan.style.position = 'absolute'; testSpan.style.whiteSpace = 'pre'; testSpan.textContent = 'a'.repeat(100); document.body.appendChild(testSpan); cachedCharWidth = testSpan.getBoundingClientRect().width / 100; document.body.removeChild(testSpan); // Calculate line height const computed = parseFloat(styles.lineHeight); if (!Number.isNaN(computed)) { cachedLineHeight = computed; } else { const fontSize = parseFloat(styles.fontSize) || 14; cachedLineHeight = fontSize * 1.5; } isGeometryInitialized = true; lineCache.clear(); } function refreshEditorWidth() { if (!markdownEditor) return; if (!isGeometryInitialized) { initEditorGeometry(); } cachedEditorWidth = markdownEditor.clientWidth; const availableWidth = cachedEditorWidth - cachedPaddingLeft - cachedPaddingRight; const nextMaxCharsPerLine = Math.max(1, Math.floor(availableWidth / cachedCharWidth)); if (nextMaxCharsPerLine !== cachedMaxCharsPerLine) { cachedMaxCharsPerLine = nextMaxCharsPerLine; lineCache.clear(); } cachedScrollTop = markdownEditor.scrollTop; cachedScrollLeft = markdownEditor.scrollLeft; } function updateLineNumberItem(item, index, lineText, lineHeight) { if (!item) return; let wrapHeight = lineCache.get(lineText); if (wrapHeight === undefined) { const wrapCount = getWrappedLineCountMonospace(lineText, cachedMaxCharsPerLine); wrapHeight = wrapCount * lineHeight; if (lineCache.size >= LINE_CACHE_MAX_ENTRIES) { lineCache.clear(); } lineCache.set(lineText, wrapHeight); } const targetText = String(index + 1); if (item.textContent !== targetText) { item.textContent = targetText; } const targetHeight = `${wrapHeight}px`; if (item.style.height !== targetHeight) { item.style.height = targetHeight; } } function updateActiveLineNumberHeight(text, lineCount, lineHeight) { const caret = markdownEditor.selectionStart || 0; const lineIndex = Math.min(lineCount - 1, countLinesBeforeIndex(text, caret)); const lineStart = text.lastIndexOf('\n', Math.max(0, caret - 1)) + 1; const lineEndIndex = text.indexOf('\n', caret); const lineEnd = lineEndIndex === -1 ? text.length : lineEndIndex; updateLineNumberItem(lineNumbers.children[lineIndex], lineIndex, text.slice(lineStart, lineEnd), lineHeight); } function updateLineNumbers(options) { const opts = options || {}; if (!lineNumbers || !markdownEditor) return; if (!isEditorVisible()) return; const text = markdownEditor.value || ''; const lineCount = countLinesFast(text); if (cachedEditorWidth === 0) { refreshEditorWidth(); } updateLineNumberGutter(lineCount); const lineHeight = cachedLineHeight; const existingItems = lineNumbers.children; const existingCount = existingItems.length; const isLargeEditorDocument = text.length >= LARGE_DOCUMENT_THRESHOLD; const canUseActiveLineFastPath = isLargeEditorDocument && !opts.force && lineCount === lastLineNumberLineCount && existingCount === lineCount; if (canUseActiveLineFastPath) { updateActiveLineNumberHeight(text, lineCount, lineHeight); lastLineNumberDocumentLength = text.length; syncLineNumberScroll(); return; } const lines = text.split('\n'); // Adjust the number of DOM elements in-place to avoid complete tear-down if (existingCount < lineCount) { const fragment = document.createDocumentFragment(); for (let i = existingCount; i < lineCount; i += 1) { const lineNumber = document.createElement('div'); lineNumber.className = 'line-number'; fragment.appendChild(lineNumber); } lineNumbers.appendChild(fragment); } else if (existingCount > lineCount) { while (lineNumbers.children.length > lineCount) { lineNumbers.removeChild(lineNumbers.lastChild); } } // Update only the heights and numbers that changed, using monospace simulator to avoid forced reflows for (let i = 0; i < lineCount; i += 1) { updateLineNumberItem(existingItems[i], i, lines[i], lineHeight); } lastLineNumberLineCount = lineCount; lastLineNumberDocumentLength = text.length; syncLineNumberScroll(); } function scheduleLineNumberUpdate(options) { const opts = options || {}; if (!lineNumbers) return; if (!isEditorVisible()) return; if (opts.force) { if (lineNumberUpdateTimeout) { clearTimeout(lineNumberUpdateTimeout); lineNumberUpdateTimeout = null; } if (lineNumberUpdateFrame) { cancelAnimationFrame(lineNumberUpdateFrame); lineNumberUpdateFrame = null; } } else if (lineNumberUpdateFrame || lineNumberUpdateTimeout) { return; } const text = markdownEditor ? markdownEditor.value || '' : ''; const delay = opts.delay !== undefined ? opts.delay : (opts.inputType === 'insertFromPaste' || text.length >= LARGE_DOCUMENT_THRESHOLD ? getEditorWorkDelay(text) : 0); const runUpdate = function() { lineNumberUpdateFrame = window.requestAnimationFrame(function() { lineNumberUpdateFrame = null; updateLineNumbers(opts); }); }; if (delay > 0) { lineNumberUpdateTimeout = setTimeout(function() { lineNumberUpdateTimeout = null; runUpdate(); }, delay); } else { runUpdate(); } } function syncLineNumberScroll() { if (!lineNumbers) return; lineNumbers.scrollTop = cachedScrollTop; } function syncEditorScrollOverlays() { cachedScrollTop = markdownEditor.scrollTop; cachedScrollLeft = markdownEditor.scrollLeft; syncHighlightScroll(); syncLineNumberScroll(); } function clampEditorScrollTop(scrollTop) { const maxScrollTop = Math.max(0, markdownEditor.scrollHeight - markdownEditor.clientHeight); return Math.min(maxScrollTop, Math.max(0, scrollTop)); } function estimateEditorOffsetForIndex(index) { if (!isGeometryInitialized || cachedEditorWidth !== markdownEditor.clientWidth) { refreshEditorWidth(); } const styles = window.getComputedStyle(markdownEditor); const paddingTop = parseFloat(styles.paddingTop) || 10; const textBefore = (markdownEditor.value || '').slice(0, Math.max(0, index)); const lines = textBefore.split('\n'); let visualRows = 0; for (let i = 0; i < lines.length - 1; i += 1) { visualRows += getWrappedLineCountMonospace(lines[i], cachedMaxCharsPerLine); } const currentLinePrefix = lines[lines.length - 1] || ''; visualRows += Math.max(0, getWrappedLineCountMonospace(currentLinePrefix, cachedMaxCharsPerLine) - 1); return paddingTop + (visualRows * cachedLineHeight); } function getActiveFindHighlight() { if (!editorHighlightLayer) return null; return editorHighlightLayer.querySelector('.find-highlight.active'); } function scrollActiveMatchIntoView(match) { let matchTop = null; let matchHeight = cachedLineHeight; let activeHighlight = getActiveFindHighlight(); if (!activeHighlight) { updateFindHighlights(); activeHighlight = getActiveFindHighlight(); } if (activeHighlight) { matchTop = activeHighlight.offsetTop; matchHeight = activeHighlight.offsetHeight || matchHeight; } else { matchTop = estimateEditorOffsetForIndex(match.start); } const targetScrollTop = clampEditorScrollTop( matchTop - (markdownEditor.clientHeight / 2) + (matchHeight / 2) ); markdownEditor.scrollTop = targetScrollTop; syncEditorScrollOverlays(); } // Class encapsulating Search & Replace Engine class FindReplaceEngine { constructor(editor) { this.editor = editor; this.history = { find: [], replace: [] }; this.activeMatches = []; this.currentMatchIndex = -1; this._cachedScopeText = null; this._cachedScopeMap = null; } buildASTScopeMap(text) { // PERF-026: Cache scope map to avoid re-lexing on every search keystroke if (text === this._cachedScopeText && this._cachedScopeMap) { return this._cachedScopeMap; } if (typeof marked === 'undefined' || !marked.lexer) return []; try { const tokens = marked.lexer(text); const scopeMap = []; let currentIndex = 0; const traverse = (tokenList) => { for (const token of tokenList) { const start = text.indexOf(token.raw, currentIndex); if (start === -1) continue; const end = start + token.raw.length; currentIndex = end; let scope = 'plain'; if (token.type === 'heading') scope = 'heading'; else if (token.type === 'code') { if (token.lang === 'mermaid') scope = 'mermaid'; else scope = 'code'; } else if (token.type === 'paragraph' && token.raw.startsWith('$$') && token.raw.endsWith('$$')) { scope = 'latex'; } scopeMap.push({ start, end, scope, type: token.type }); if (token.tokens) traverse(token.tokens); } }; traverse(tokens); this._cachedScopeText = text; this._cachedScopeMap = scopeMap; return scopeMap; } catch (e) { console.warn("AST scope parsing failed:", e); return []; } } compileRegExp(query, isRegex, isCaseSensitive, isWholeWord) { if (!query) return null; let pattern = isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); if (isWholeWord) { pattern = `\\b${pattern}\\b`; } const flags = isCaseSensitive ? 'gd' : 'gid'; return new RegExp(pattern, flags); } executeSearch(options) { const { query, isRegex, isCaseSensitive, isWholeWord, scopeFilter, findInSelection } = options; const fullText = this.editor.value || ''; let searchRange = { start: 0, end: fullText.length }; if (findInSelection) { searchRange.start = this.editor.selectionStart; searchRange.end = this.editor.selectionEnd; } let regex; try { regex = this.compileRegExp(query, isRegex, isCaseSensitive, isWholeWord); } catch (err) { throw new Error(err.message); } if (!regex) { this.activeMatches = []; this.currentMatchIndex = -1; return this.activeMatches; } const rawMatches = []; let match; while ((match = regex.exec(fullText)) !== null) { if (match.index >= searchRange.end) break; if (match.index >= searchRange.start) { rawMatches.push({ start: match.index, end: match.index + match[0].length, value: match[0], groups: match.groups || null, matchArray: match }); } if (regex.lastIndex === match.index) { regex.lastIndex++; } } if (scopeFilter && scopeFilter !== 'entire') { const scopeMap = this.buildASTScopeMap(fullText); this.activeMatches = rawMatches.filter(m => { const matchingScope = scopeMap.find(s => m.start >= s.start && m.end <= s.end); return matchingScope && matchingScope.scope === scopeFilter; }); } else { this.activeMatches = rawMatches; } this.currentMatchIndex = this.activeMatches.length > 0 ? 0 : -1; this.addHistory('find', query); return this.activeMatches; } preserveCase(source, replacement) { if (source === source.toUpperCase()) { return replacement.toUpperCase(); } if (source === source.toLowerCase()) { return replacement.toLowerCase(); } if (source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) { return replacement[0].toUpperCase() + replacement.slice(1).toLowerCase(); } return replacement; } applyCaptureGroups(match, replacementTemplate) { if (!match.matchArray) return replacementTemplate; let result = replacementTemplate; result = result.replace(/\$(\d+)/g, (m, number) => { const idx = parseInt(number, 10); return match.matchArray[idx] !== undefined ? match.matchArray[idx] : m; }); if (match.groups) { result = result.replace(/\$<([^>]+)>/g, (m, name) => { return match.groups[name] !== undefined ? match.groups[name] : m; }); } return result; } executeReplace(match, replacementTemplate, options) { const { preserveCase, isRegex } = options; const text = this.editor.value; let finalReplacement = replacementTemplate; if (isRegex) { finalReplacement = this.applyCaptureGroups(match, finalReplacement); } if (preserveCase) { finalReplacement = this.preserveCase(match.value, finalReplacement); } const before = text.slice(0, match.start); const after = text.slice(match.end); this.editor.value = before + finalReplacement + after; this.editor.dispatchEvent(new Event('input', { bubbles: true })); this.addHistory('replace', replacementTemplate); return finalReplacement.length - match.value.length; } addHistory(type, query) { if (!query) return; const list = this.history[type]; const index = list.indexOf(query); if (index !== -1) { list.splice(index, 1); } list.unshift(query); if (list.length > 10) { list.pop(); } } } // Verification helper for rich text blocks function validateBlockSyntax(originalBlockText, newBlockText, scope) { if (scope === 'latex') { const origDisplay = (originalBlockText.match(/\$\$/g) || []).length; const newDisplay = (newBlockText.match(/\$\$/g) || []).length; const origInline = (originalBlockText.match(/[^\$]\$[^\$]/g) || []).length; const newInline = (newBlockText.match(/[^\$]\$[^\$]/g) || []).length; if (origDisplay !== newDisplay || origInline !== newInline) { return { valid: false, reason: "LaTeX math block delimiters are unbalanced." }; } } if (scope === 'mermaid') { const diagramTypePattern = /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram-v2|erDiagram|gantt|pie|quadrantChart|c4Context|mindmap|timeline|zenuml)/i; if (!diagramTypePattern.test(newBlockText.trim())) { return { valid: false, reason: "Missing diagram type definition (e.g. flowchart TD)." }; } } return { valid: true }; } // Global Engine Instance let frEngine = null; let isFrDocked = false; let dragOffset = { x: 0, y: 0 }; let isPanelDragging = false; let lastFloatingLeft = null; let lastFloatingTop = null; let lastFloatingRight = null; function initFindReplacePanelDrag() { const handle = document.getElementById('find-replace-drag-handle'); const panel = document.getElementById('find-replace-modal'); if (!handle || !panel) return; const startDrag = (clientX, clientY) => { isPanelDragging = true; dragOffset.x = clientX - panel.offsetLeft; dragOffset.y = clientY - panel.offsetTop; document.body.classList.add('resizing'); }; const moveDrag = (clientX, clientY) => { const x = clientX - dragOffset.x; const y = clientY - dragOffset.y; // Keep panel inside viewport boundaries const maxX = window.innerWidth - panel.offsetWidth; const maxY = window.innerHeight - panel.offsetHeight; const newLeft = `${Math.max(0, Math.min(maxX, x))}px`; const newTop = `${Math.max(0, Math.min(maxY, y))}px`; panel.style.left = newLeft; panel.style.top = newTop; panel.style.right = 'auto'; lastFloatingLeft = newLeft; lastFloatingTop = newTop; lastFloatingRight = 'auto'; }; const stopDrag = () => { if (isPanelDragging) { isPanelDragging = false; document.body.classList.remove('resizing'); } }; // Mouse events handle.addEventListener('mousedown', (e) => { if (isFrDocked) return; if (window.innerWidth < 768) return; // Do NOT allow dragging on mobile layouts if (e.target.closest('.find-replace-header-actions')) return; startDrag(e.clientX, e.clientY); }); document.addEventListener('mousemove', (e) => { if (!isPanelDragging || isFrDocked) return; moveDrag(e.clientX, e.clientY); }); document.addEventListener('mouseup', stopDrag); // Touch events for tablets handle.addEventListener('touchstart', (e) => { if (isFrDocked) return; if (window.innerWidth < 768) return; // Do NOT allow dragging on mobile layouts if (e.target.closest('.find-replace-header-actions')) return; if (e.touches && e.touches[0]) { startDrag(e.touches[0].clientX, e.touches[0].clientY); } }, { passive: true }); document.addEventListener('touchmove', (e) => { if (!isPanelDragging || isFrDocked) return; if (e.touches && e.touches[0]) { moveDrag(e.touches[0].clientX, e.touches[0].clientY); } }, { passive: true }); document.addEventListener('touchend', stopDrag); } let frPreferredDocked = false; function toggleFrDockMode(forceFloat = false) { // If forceFloat is an Event (e.g. from click listener directly), treat as false if (forceFloat instanceof Event || (forceFloat && typeof forceFloat === 'object')) { forceFloat = false; } const panel = document.getElementById('find-replace-modal'); const dockBtn = document.getElementById('find-replace-dock'); const contentCont = document.querySelector('.content-container'); if (!panel || !dockBtn || !contentCont) return; // Save active element focus and selection before DOM movement const activeEl = document.activeElement; const activeId = activeEl ? activeEl.id : null; const isInput = activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'SELECT' || activeEl.tagName === 'TEXTAREA'); let selStart = 0; let selEnd = 0; if (isInput && typeof activeEl.selectionStart === 'number') { selStart = activeEl.selectionStart; selEnd = activeEl.selectionEnd; } if (window.innerWidth < 1080 || forceFloat) { isFrDocked = false; panel.classList.remove('docked'); if (panel.parentElement !== document.body) { document.body.appendChild(panel); } contentCont.classList.remove('fr-docked'); contentCont.style.setProperty('--dock-width', '0px'); panel.style.left = lastFloatingLeft !== null ? lastFloatingLeft : ''; panel.style.top = lastFloatingTop !== null ? lastFloatingTop : ''; panel.style.right = lastFloatingRight !== null ? lastFloatingRight : ''; dockBtn.innerHTML = ''; dockBtn.title = "Toggle Dock Mode"; panel.style.display = 'flex'; applyPaneWidths(); // Restore focus and selection if (activeId) { const el = document.getElementById(activeId); if (el) { el.focus(); if (isInput && typeof el.selectionStart === 'number') { el.setSelectionRange(selStart, selEnd); } } } return; } isFrDocked = !isFrDocked; // Save preference to localStorage frPreferredDocked = isFrDocked; localStorage.setItem('find-replace-docked', frPreferredDocked ? 'true' : 'false'); if (isFrDocked) { panel.classList.add('docked'); panel.style.left = 'auto'; panel.style.top = 'auto'; panel.style.right = 'auto'; // Append panel to dock container (.content-container) contentCont.appendChild(panel); contentCont.classList.add('fr-docked'); contentCont.style.setProperty('--dock-width', '340px'); dockBtn.innerHTML = ''; dockBtn.title = "Toggle Floating Mode"; } else { panel.classList.remove('docked'); // Reset position and float on body document.body.appendChild(panel); contentCont.classList.remove('fr-docked'); contentCont.style.setProperty('--dock-width', '0px'); panel.style.left = lastFloatingLeft !== null ? lastFloatingLeft : ''; panel.style.top = lastFloatingTop !== null ? lastFloatingTop : ''; panel.style.right = lastFloatingRight !== null ? lastFloatingRight : ''; dockBtn.innerHTML = ''; dockBtn.title = "Toggle Dock Mode"; } // Ensure display is flex and recalculate split panes panel.style.display = 'flex'; applyPaneWidths(); // Restore focus and selection after layout change if (activeId) { const el = document.getElementById(activeId); if (el) { el.focus(); if (isInput && typeof el.selectionStart === 'number') { el.setSelectionRange(selStart, selEnd); } } } } function updateFindControls() { const total = findMatches.length; const current = total && activeFindIndex >= 0 ? activeFindIndex + 1 : 0; const countSpan = document.getElementById('find-replace-count'); if (countSpan) { countSpan.textContent = `${current} of ${total} matches`; } const prevBtn = document.getElementById('find-prev'); const nextBtn = document.getElementById('find-next'); const replaceCurrentBtn = document.getElementById('find-replace-current'); const replaceAllBtn = document.getElementById('find-replace-all'); const hasMatches = total > 0; const hasQuery = !!(findReplaceInput && findReplaceInput.value); if (prevBtn) prevBtn.disabled = !hasMatches; if (nextBtn) nextBtn.disabled = !hasMatches; if (replaceCurrentBtn) replaceCurrentBtn.disabled = !hasMatches; if (replaceAllBtn) replaceAllBtn.disabled = !hasQuery || !hasMatches; } function refreshFindMatches(options) { clearTimeout(findRefreshTimeout); findRefreshTimeout = null; const opts = options || {}; const query = findReplaceInput ? findReplaceInput.value : ''; const errorBox = document.getElementById('find-replace-error'); const errorMsg = document.getElementById('regex-error-msg'); if (errorBox) errorBox.style.display = 'none'; if (!isFindModalOpen || !query) { findMatches = []; activeFindIndex = -1; updateFindControls(); updateFindHighlights(); return; } const isRegex = document.getElementById('find-regex').classList.contains('active'); const isCaseSensitive = document.getElementById('find-case').classList.contains('active'); const isWholeWord = document.getElementById('find-word').classList.contains('active'); const scopeFilter = document.getElementById('find-replace-scope').value; const findInSelection = document.getElementById('find-sel').classList.contains('active'); try { findMatches = frEngine.executeSearch({ query, isRegex, isCaseSensitive, isWholeWord, scopeFilter, findInSelection }); } catch (err) { findMatches = []; activeFindIndex = -1; if (errorBox && errorMsg) { errorMsg.textContent = err.message; errorBox.style.display = 'block'; } } const shouldResetActiveIndex = opts.resetIndex || query !== lastFindQuery; if (shouldResetActiveIndex) { activeFindIndex = findMatches.length ? 0 : -1; } else if (activeFindIndex >= findMatches.length) { activeFindIndex = findMatches.length - 1; } lastFindQuery = query; updateFindControls(); updateFindHighlights(); if (shouldResetActiveIndex && findMatches.length && activeFindIndex >= 0) { scrollActiveMatchIntoView(findMatches[activeFindIndex]); } updateHistoryDropdowns(); } function scheduleFindRefresh(options) { clearTimeout(findRefreshTimeout); const text = markdownEditor ? markdownEditor.value || '' : ''; const delay = text.length >= LARGE_DOCUMENT_THRESHOLD ? LARGE_FIND_REFRESH_DELAY : FIND_REFRESH_DELAY; findRefreshTimeout = setTimeout(function() { findRefreshTimeout = null; refreshFindMatches(options); }, delay); } function selectActiveMatch() { if (!findMatches.length || activeFindIndex < 0) return; const match = findMatches[activeFindIndex]; markdownEditor.focus(); markdownEditor.setSelectionRange(match.start, match.end); try { scrollActiveMatchIntoView(match); } catch (e) { console.warn("Viewport centering scroll failed:", e); } } function cycleFindMatch(direction) { const totalMatches = findMatches.length; if (!totalMatches) return; activeFindIndex = (activeFindIndex + direction + totalMatches) % totalMatches; updateFindControls(); updateFindHighlights(); selectActiveMatch(); } function openFindReplaceModal() { if (!findReplaceModal || !findReplaceInput) return; if (!frEngine) { frEngine = new FindReplaceEngine(markdownEditor); } isFindModalOpen = true; const selected = markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd); if (selected && selected.length < 100) { findReplaceInput.value = selected; } // Restore docked/floating mode preference let wasDockedPref = localStorage.getItem('find-replace-docked') === 'true'; // Force floating-only mode on mobile/tablet viewports if (window.innerWidth < 1080) { wasDockedPref = false; } if (wasDockedPref) { isFrDocked = false; // Set false so toggleFrDockMode() turns it to true toggleFrDockMode(); } else { isFrDocked = true; // Set true so toggleFrDockMode() turns it to false toggleFrDockMode(); } findReplaceModal.style.display = 'flex'; requestAnimationFrame(function() { findReplaceInput.focus(); findReplaceInput.select(); }); refreshFindMatches({ resetIndex: true }); if (findMatches.length) { selectActiveMatch(); } } function closeFindReplaceModal() { isFindModalOpen = false; const panel = document.getElementById('find-replace-modal'); const contentCont = document.querySelector('.content-container'); if (panel) { panel.style.display = 'none'; if (isFrDocked) { // Reset split layout styles when closed if (contentCont) { contentCont.classList.remove('fr-docked'); contentCont.style.setProperty('--dock-width', '0px'); applyPaneWidths(); } } } findMatches = []; activeFindIndex = -1; updateFindControls(); updateFindHighlights(); } function replaceCurrentMatch() { if (!findMatches.length || activeFindIndex < 0) return; const replacement = findReplaceWith ? findReplaceWith.value : ''; const match = findMatches[activeFindIndex]; const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active'); const isRegex = document.getElementById('find-regex').classList.contains('active'); // Syntax validation const scopeFilter = document.getElementById('find-replace-scope').value; if (scopeFilter === 'latex' || scopeFilter === 'mermaid') { const check = validateBlockSyntax(match.value, replacement, scopeFilter); if (!check.valid) { alert(`Blocked replacement: ${check.reason}`); return; } } frEngine.executeReplace(match, replacement, { preserveCase, isRegex }); refreshFindMatches(); if (findMatches.length) { activeFindIndex = Math.min(activeFindIndex, findMatches.length - 1); selectActiveMatch(); } } function replaceAllMatches() { if (!findMatches.length) return; const query = findReplaceInput ? findReplaceInput.value : ''; const replacement = findReplaceWith ? findReplaceWith.value : ''; const showDiff = document.getElementById('find-replace-diff-toggle').checked; if (showDiff) { renderDiffPreview(); return; } executeBulkReplace(); } function executeBulkReplace() { const replacement = findReplaceWith ? findReplaceWith.value : ''; const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active'); const isRegex = document.getElementById('find-regex').classList.contains('active'); // Reverse sorting to replace from bottom up const matchesCopy = [...findMatches]; matchesCopy.sort((a, b) => b.start - a.start); for (const match of matchesCopy) { frEngine.executeReplace(match, replacement, { preserveCase, isRegex }); } refreshFindMatches({ resetIndex: true }); if (findMatches.length) { selectActiveMatch(); } } function renderDiffPreview() { const container = document.getElementById('find-replace-diff-container'); const modal = document.getElementById('find-replace-diff-modal'); if (!container || !modal) return; const replacement = findReplaceWith ? findReplaceWith.value : ''; const preserveCase = document.getElementById('replace-preserve-case').classList.contains('active'); const isRegex = document.getElementById('find-regex').classList.contains('active'); const lines = markdownEditor.value.split('\n'); const matchesCopy = [...findMatches]; matchesCopy.sort((a, b) => b.start - a.start); // Draft the replaced value in memory let draftValue = markdownEditor.value; for (const match of matchesCopy) { let finalRepl = replacement; if (isRegex) { finalRepl = frEngine.applyCaptureGroups(match, finalRepl); } if (preserveCase) { finalRepl = frEngine.preserveCase(match.value, finalRepl); } draftValue = draftValue.slice(0, match.start) + finalRepl + draftValue.slice(match.end); } const draftLines = draftValue.split('\n'); // PERF-007: Clear elements using textContent container.textContent = ''; const fragment = document.createDocumentFragment(); const maxLines = Math.max(lines.length, draftLines.length); for (let i = 0; i < maxLines; i++) { const origLine = lines[i] !== undefined ? lines[i] : null; const newLine = draftLines[i] !== undefined ? draftLines[i] : null; if (origLine !== newLine) { if (origLine !== null) { const delLine = document.createElement('div'); delLine.className = 'diff-line deletion'; delLine.innerHTML = `${i + 1}- ${escapeHtml(origLine)}`; fragment.appendChild(delLine); } if (newLine !== null) { const addLine = document.createElement('div'); addLine.className = 'diff-line addition'; addLine.innerHTML = `${i + 1}+ ${escapeHtml(newLine)}`; fragment.appendChild(addLine); } } else if (origLine !== null) { // Show context for matching lines to prevent giant blank diff spaces // Show context only if it surrounds a modified line const hasDiffNearby = Array.from({length: 5}, (_, idx) => i - 2 + idx) .some(lineIdx => lines[lineIdx] !== undefined && draftLines[lineIdx] !== undefined && lines[lineIdx] !== draftLines[lineIdx]); if (hasDiffNearby) { const ctxLine = document.createElement('div'); ctxLine.className = 'diff-line context'; ctxLine.innerHTML = `${i + 1} ${escapeHtml(origLine)}`; fragment.appendChild(ctxLine); } } } container.appendChild(fragment); openAppModal(modal, { focusTarget: document.getElementById('find-replace-diff-confirm'), onClose: () => closeAppModal(modal) }); } function updateHistoryDropdowns() { const select = document.getElementById('find-replace-history'); if (!select || !frEngine) return; // Preserve the first option select.innerHTML = ''; frEngine.history.find.forEach(q => { const opt = document.createElement('option'); opt.value = q; opt.textContent = q; select.appendChild(opt); }); } function initFindReplaceModal() { const modal = document.getElementById('find-replace-modal'); if (!modal) return; initFindReplacePanelDrag(); // Toggle options const toggleButtons = ['find-case', 'find-word', 'find-regex', 'find-sel', 'replace-preserve-case', 'find-wrap']; toggleButtons.forEach(id => { const btn = document.getElementById(id); if (btn) { btn.addEventListener('click', () => { const isActive = btn.classList.contains('active'); if (isActive) { btn.classList.remove('active'); btn.setAttribute('aria-pressed', 'false'); } else { btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true'); } refreshFindMatches({ resetIndex: true }); }); } }); // History select handler const historySelect = document.getElementById('find-replace-history'); if (historySelect) { historySelect.addEventListener('change', () => { if (historySelect.value) { findReplaceInput.value = historySelect.value; refreshFindMatches({ resetIndex: true }); } }); } // Scope select handler const scopeSelect = document.getElementById('find-replace-scope'); if (scopeSelect) { scopeSelect.addEventListener('change', () => { refreshFindMatches({ resetIndex: true }); }); } // Reset position handler const resetBtn = document.getElementById('find-replace-reset'); const resetFooterBtn = document.getElementById('find-replace-reset-footer'); const doResetPosition = () => { lastFloatingLeft = null; lastFloatingTop = null; lastFloatingRight = null; modal.style.left = ''; modal.style.top = ''; modal.style.right = ''; }; if (resetBtn) { resetBtn.addEventListener('click', doResetPosition); } if (resetFooterBtn) { resetFooterBtn.addEventListener('click', doResetPosition); } // Dock toggle handler const dockBtn = document.getElementById('find-replace-dock'); if (dockBtn) { dockBtn.addEventListener('click', () => toggleFrDockMode(false)); } // Advanced Drawer Toggle const drawerToggle = document.getElementById('fr-drawer-toggle'); const drawerContent = document.getElementById('fr-drawer-content'); if (drawerToggle && drawerContent) { drawerToggle.addEventListener('click', () => { const isOpen = drawerContent.style.display === 'flex'; if (isOpen) { drawerContent.style.display = 'none'; drawerToggle.setAttribute('aria-expanded', 'false'); drawerToggle.innerHTML = ' Advanced Options'; } else { drawerContent.style.display = 'flex'; drawerToggle.setAttribute('aria-expanded', 'true'); drawerToggle.innerHTML = ' Advanced Options'; } }); } // Inputs if (findReplaceInput) { findReplaceInput.addEventListener('input', function() { refreshFindMatches({ resetIndex: true }); }); findReplaceInput.addEventListener('keydown', function(event) { if (event.key === 'Enter') { event.preventDefault(); cycleFindMatch(event.shiftKey ? -1 : 1); } }); } if (findReplaceWith) { findReplaceWith.addEventListener('input', updateFindControls); } // Navigation buttons const prevBtn = document.getElementById('find-prev'); const nextBtn = document.getElementById('find-next'); if (prevBtn) prevBtn.addEventListener('click', () => cycleFindMatch(-1)); if (nextBtn) nextBtn.addEventListener('click', () => cycleFindMatch(1)); // Action buttons const currentBtn = document.getElementById('find-replace-current'); const allBtn = document.getElementById('find-replace-all'); if (currentBtn) currentBtn.addEventListener('click', replaceCurrentMatch); if (allBtn) allBtn.addEventListener('click', replaceAllMatches); // Close buttons const closeBtn = document.getElementById('find-replace-close'); const closeIcon = document.getElementById('find-replace-close-icon'); if (closeBtn) closeBtn.addEventListener('click', closeFindReplaceModal); if (closeIcon) closeIcon.addEventListener('click', closeFindReplaceModal); // Diff modal confirmation triggers const diffConfirmBtn = document.getElementById('find-replace-diff-confirm'); const diffCancelBtn = document.getElementById('find-replace-diff-cancel'); const diffCloseIcon = document.getElementById('find-replace-diff-close-icon'); const diffModal = document.getElementById('find-replace-diff-modal'); if (diffConfirmBtn) { diffConfirmBtn.addEventListener('click', () => { executeBulkReplace(); closeAppModal(diffModal); }); } if (diffCancelBtn) diffCancelBtn.addEventListener('click', () => closeAppModal(diffModal)); if (diffCloseIcon) diffCloseIcon.addEventListener('click', () => closeAppModal(diffModal)); } function initAppModals() { if (clearFormattingConfirm) { clearFormattingConfirm.addEventListener('click', function() { applyClearFormatting(); closeAppModal(clearFormattingModal); }); } if (clearFormattingCancel) { clearFormattingCancel.addEventListener('click', function() { closeAppModal(clearFormattingModal); }); } if (clearFormattingClose) { clearFormattingClose.addEventListener('click', function() { closeAppModal(clearFormattingModal); }); } if (helpModalClose) { helpModalClose.addEventListener('click', function() { closeAppModal(helpModal); }); } if (helpModalCloseIcon) { helpModalCloseIcon.addEventListener('click', function() { closeAppModal(helpModal); }); } if (aboutModalClose) { aboutModalClose.addEventListener('click', function() { closeAppModal(aboutModal); }); } if (aboutModalCloseIcon) { aboutModalCloseIcon.addEventListener('click', function() { closeAppModal(aboutModal); }); } } function openHelpModal() { if (helpModal) { openAppModal(helpModal); } } function openAboutModal() { if (aboutModal) { const aboutVersion = document.getElementById("about-version"); if (aboutVersion) { aboutVersion.textContent = APP_VERSION; } openAppModal(aboutModal); } } function openClearFormattingModal() { if (clearFormattingModal) { openAppModal(clearFormattingModal); } } function runMarkdownTool(action, button) { if (action === 'undo') { executeUndo(); return; } if (action === 'redo') { executeRedo(); return; } if (action === 'bold') wrapEditorSelection('**', '**', 'bold text'); else if (action === 'strike') wrapEditorSelection('~~', '~~', 'struck text'); else if (action === 'italic') wrapEditorSelection('*', '*', 'italic text'); else if (action === 'quote') transformEditorLines(function(line) { return line ? '> ' + line.replace(/^>\s?/, '') : '>'; }); else if (action === 'align-left') insertAlignmentBlock('left'); else if (action === 'align-center') insertAlignmentBlock('center'); else if (action === 'align-right') insertAlignmentBlock('right'); else if (action === 'title-case') transformSelectionOrCurrentLine(toTitleCase); else if (action === 'uppercase') transformSelectionOrCurrentLine(function(text) { return text.toUpperCase(); }); else if (action === 'lowercase') transformSelectionOrCurrentLine(function(text) { return text.toLowerCase(); }); else if (action === 'heading') { const level = parseInt(button.getAttribute('data-md-level') || '1', 10); const marker = '#'.repeat(Math.max(1, Math.min(6, level))) + ' '; transformEditorLines(function(line) { return marker + line.replace(/^#{1,6}\s+/, ''); }); } else if (action === 'unordered-list') { applyMarkdownList('unordered'); } else if (action === 'ordered-list') { applyMarkdownList('ordered'); } else if (action === 'horizontal-rule') insertMarkdownBlock('---\n'); else if (action === 'link') insertMarkdownLink(); else if (action === 'reference') insertMarkdownReference(); else if (action === 'image') insertMarkdownImage(); else if (action === 'inline-code') wrapEditorSelection('`', '`', 'code'); else if (action === 'code-block') insertMarkdownBlock('```js\n' + (markdownEditor.value.slice(markdownEditor.selectionStart, markdownEditor.selectionEnd) || 'console.log("Hello, Markdown!");') + '\n```\n'); else if (action === 'table') openTableModal(); else if (action === 'date-time') { const now = new Date(); const datePart = now.toLocaleDateString('en-CA'); const timePart = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); const dayName = now.toLocaleDateString('en-US', { weekday: 'long' }); const timestamp = `${datePart} ${timePart} ${dayName}`; replaceEditorRange(markdownEditor.selectionStart, markdownEditor.selectionEnd, timestamp, markdownEditor.selectionStart + timestamp.length, markdownEditor.selectionStart + timestamp.length); } else if (action === 'emoji') { openEmojiModal(); } else if (action === 'symbols') openSymbolsModal(); else if (action === 'alert') openAlertModal(); else if (action === 'terminal-block') insertMarkdownBlock('```bash\nnpm run dev\n```\n'); else if (action === 'fullscreen') { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen(); } else if (action === 'clear-formatting') openClearFormattingModal(); else if (action === 'find') openFindReplaceModal(); else if (action === 'help') openHelpModal(); else if (action === 'info') openAboutModal(); } function initMarkdownFormatToolbar() { if (!markdownFormatToolbar) return; markdownFormatToolbar.addEventListener('mousedown', function(e) { if (e.target.closest('[data-md-action]')) e.preventDefault(); }); markdownFormatToolbar.addEventListener('click', function(e) { const button = e.target.closest('[data-md-action]'); if (!button) return; e.preventDefault(); runMarkdownTool(button.getAttribute('data-md-action'), button); }); } // Story 1.3: Resize Divider Functions function initResizer() { if (!resizeDivider) return; // Set up WAI-ARIA accessibility tags resizeDivider.setAttribute('role', 'separator'); resizeDivider.setAttribute('aria-orientation', 'vertical'); resizeDivider.setAttribute('tabindex', '0'); resizeDivider.setAttribute('aria-valuemin', MIN_PANE_PERCENT.toString()); resizeDivider.setAttribute('aria-valuemax', (100 - MIN_PANE_PERCENT).toString()); updateResizerAria(); resizeDivider.addEventListener('mousedown', startResize); document.addEventListener('mousemove', handleResize); document.addEventListener('mouseup', stopResize); // Touch support for tablets (though disabled via CSS, keeping for future) resizeDivider.addEventListener('touchstart', startResizeTouch); document.addEventListener('touchmove', handleResizeTouch); document.addEventListener('touchend', stopResize); resizeDivider.addEventListener('keydown', handleResizerKeydown); function handleResizerKeydown(e) { if (currentViewMode !== 'split') return; let delta = 0; if (e.key === 'ArrowLeft') { delta = -5; // Shift left by 5% } else if (e.key === 'ArrowRight') { delta = 5; // Shift right by 5% } else { return; } e.preventDefault(); editorWidthPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, editorWidthPercent + delta)); applyPaneWidths(); updateResizerAria(); } function updateResizerAria() { resizeDivider.setAttribute('aria-valuenow', Math.round(editorWidthPercent)); } } function startResize(e) { if (window.innerWidth < 1080) return; if (currentViewMode !== 'split') return; e.preventDefault(); isResizing = true; resizeDivider.classList.add('dragging'); document.body.classList.add('resizing'); } function startResizeTouch(e) { if (window.innerWidth < 1080) return; if (currentViewMode !== 'split') return; e.preventDefault(); isResizing = true; resizeDivider.classList.add('dragging'); document.body.classList.add('resizing'); } function handleResize(e) { if (!isResizing) return; const containerRect = contentContainer.getBoundingClientRect(); const containerWidth = containerRect.width; const mouseX = e.clientX - containerRect.left; // Calculate percentage let newEditorPercent = (mouseX / containerWidth) * 100; // Enforce minimum pane widths newEditorPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, newEditorPercent)); editorWidthPercent = newEditorPercent; applyPaneWidths(); } function handleResizeTouch(e) { if (!isResizing || !e.touches[0]) return; const containerRect = contentContainer.getBoundingClientRect(); const containerWidth = containerRect.width; const touchX = e.touches[0].clientX - containerRect.left; let newEditorPercent = (touchX / containerWidth) * 100; newEditorPercent = Math.max(MIN_PANE_PERCENT, Math.min(100 - MIN_PANE_PERCENT, newEditorPercent)); editorWidthPercent = newEditorPercent; applyPaneWidths(); } function stopResize() { if (!isResizing) return; isResizing = false; resizeDivider.classList.remove('dragging'); document.body.classList.remove('resizing'); } function applyPaneWidths() { if (window.innerWidth < 1080) { resetPaneWidths(); return; } if (currentViewMode !== 'split') return; const previewPercent = 100 - editorWidthPercent; editorPaneElement.style.flex = `0 0 calc((100% - var(--dock-width, 0px)) * ${editorWidthPercent / 100} - 4px)`; previewPaneElement.style.flex = `0 0 calc((100% - var(--dock-width, 0px)) * ${previewPercent / 100} - 4px)`; refreshEditorWidth(); scheduleLineNumberUpdate(); } function resetPaneWidths() { editorPaneElement.style.flex = ''; previewPaneElement.style.flex = ''; refreshEditorWidth(); } function openMobileMenu() { mobileMenuPanel.classList.add("active"); mobileMenuOverlay.classList.add("active"); } function closeMobileMenu() { mobileMenuPanel.classList.remove("active"); mobileMenuOverlay.classList.remove("active"); } mobileMenuToggle.addEventListener("click", openMobileMenu); mobileCloseMenu.addEventListener("click", closeMobileMenu); mobileMenuOverlay.addEventListener("click", closeMobileMenu); function updateMobileStats() { mobileCharCount.textContent = charCountElement.textContent; mobileWordCount.textContent = wordCountElement.textContent; mobileReadingTime.textContent = readingTimeElement.textContent; } const origUpdateStats = updateDocumentStats; updateDocumentStats = function() { origUpdateStats(); updateMobileStats(); }; mobileToggleSync.addEventListener("click", () => { toggleSyncScrolling(); if (syncScrollingEnabled) { mobileToggleSync.innerHTML = ' Sync Off'; mobileToggleSync.classList.add("sync-disabled"); mobileToggleSync.classList.remove("sync-enabled"); mobileToggleSync.classList.add("sync-active"); } else { mobileToggleSync.innerHTML = ' Sync On'; mobileToggleSync.classList.add("sync-enabled"); mobileToggleSync.classList.remove("sync-disabled"); mobileToggleSync.classList.remove("sync-active"); } }); mobileImportBtn.addEventListener("click", () => { if (typeof Neutralino !== 'undefined') { nativeImportMarkdown(); } else { fileInput.click(); } }); mobileImportGithubBtn.addEventListener("click", () => { closeMobileMenu(); openGitHubImportModal(); }); mobileExportMd.addEventListener("click", () => exportMd.click()); mobileExportHtml.addEventListener("click", () => exportHtml.click()); mobileExportPdf.addEventListener("click", () => exportPdf.click()); mobileCopyMarkdown.addEventListener("click", () => copyMarkdownButton.click()); mobileThemeToggle.addEventListener("click", () => { themeToggle.click(); mobileThemeToggle.innerHTML = themeToggle.innerHTML + " Toggle Dark Mode"; }); const mobileNewTabBtn = document.getElementById("mobile-new-tab-btn"); if (mobileNewTabBtn) { mobileNewTabBtn.addEventListener("click", function() { newTab(); closeMobileMenu(); }); } const mobileTabResetBtn = document.getElementById("mobile-tab-reset-btn"); if (mobileTabResetBtn) { mobileTabResetBtn.addEventListener("click", function() { closeMobileMenu(); resetAllTabs(); }); } initTabs(); if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling(); updateMobileStats(); updateFindHighlights(); syncHighlightScroll(); // Defer DOM geometry measurement until after FCP/LCP critical paint path setTimeout(function() { initEditorGeometry(); refreshEditorWidth(); scheduleLineNumberUpdate(); }, 100); // Initialize resizer - Story 1.3 initResizer(); function constrainFloatingPanelPosition() { const panel = document.getElementById('find-replace-modal'); if (!panel || isFrDocked || panel.style.display === 'none') return; if (window.innerWidth < 768) return; // Mobile layout forces fixed responsive positioning via CSS // Only adjust if the inline style has custom dragged coordinates if (!panel.style.left || panel.style.left === 'auto') return; const leftVal = parseFloat(panel.style.left) || 0; const topVal = parseFloat(panel.style.top) || 0; const maxX = window.innerWidth - panel.offsetWidth; const maxY = window.innerHeight - panel.offsetHeight; const constrainedLeft = `${Math.max(0, Math.min(maxX, leftVal))}px`; const constrainedTop = `${Math.max(0, Math.min(maxY, topVal))}px`; panel.style.left = constrainedLeft; panel.style.top = constrainedTop; lastFloatingLeft = constrainedLeft; lastFloatingTop = constrainedTop; } let resizeLayoutTimeout = null; window.addEventListener('resize', () => { clearTimeout(resizeLayoutTimeout); resizeLayoutTimeout = setTimeout(function() { initEditorGeometry(); refreshEditorWidth(); scheduleLineNumberUpdate(); if (window.innerWidth < 1080 && isFrDocked && isFindModalOpen) { toggleFrDockMode(true); } constrainFloatingPanelPosition(); }, 100); }); // View Mode Button Event Listeners - Story 1.1 viewModeButtons.forEach(btn => { btn.addEventListener('click', function() { const mode = this.getAttribute('data-view-mode'); setViewMode(resolveViewToggleMode(mode)); saveCurrentTabState(); }); }); // Story 1.4: Mobile View Mode Button Event Listeners mobileViewModeButtons.forEach(btn => { btn.addEventListener('click', function() { const mode = this.getAttribute('data-mode'); setViewMode(mode); saveCurrentTabState(); closeMobileMenu(); }); }); markdownEditor.addEventListener("input", function(e) { handleKeystrokeHistory(e); debouncedRender(); clearTimeout(saveTabStateTimeout); saveTabStateTimeout = setTimeout(saveCurrentTabState, 500); if (isFindModalOpen) { scheduleFindRefresh(); } else { updateFindHighlights(); } scheduleLineNumberUpdate({ inputType: e && typeof e.inputType === 'string' ? e.inputType : '', }); }); markdownEditor.addEventListener('keydown', updateLastCursor); markdownEditor.addEventListener('keyup', updateLastCursor); markdownEditor.addEventListener('mousedown', updateLastCursor); markdownEditor.addEventListener('mouseup', updateLastCursor); markdownEditor.addEventListener('focus', updateLastCursor); initMarkdownFormatToolbar(); initFindReplaceModal(); initAppModals(); // Editor key handlers for list continuation and indentation markdownEditor.addEventListener("keydown", function(e) { if (handleListEnter(e)) { return; } if (e.key === 'Tab') { e.preventDefault(); const start = this.selectionStart; const end = this.selectionEnd; const value = this.value; // Insert 2 spaces const indent = ' '; // 2 spaces // Update textarea value this.value = value.substring(0, start) + indent + value.substring(end); // Update cursor position this.selectionStart = this.selectionEnd = start + indent.length; // Trigger input event to update preview this.dispatchEvent(new Event('input')); } }); markdownEditor.addEventListener("scroll", function() { cachedScrollTop = this.scrollTop; cachedScrollLeft = this.scrollLeft; syncEditorToPreview(); scheduleEditorOverlayScrollSync(); }); previewPane.addEventListener("scroll", syncPreviewToEditor); toggleSyncButton.addEventListener("click", toggleSyncScrolling); if (directionToggle) { directionToggle.addEventListener("click", function () { const currentDir = markdownEditor ? markdownEditor.getAttribute("dir") : "ltr"; const direction = currentDir === "rtl" ? "ltr" : "rtl"; applyDirectionToContent(direction); saveGlobalState({ direction }); updateDirectionToggleUI(direction); }); } themeToggle.addEventListener("click", function () { _lastRenderedContent = null; const theme = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", theme); saveGlobalState({ theme }); if (theme === "dark") { themeToggle.innerHTML = ''; } else { themeToggle.innerHTML = ''; } // PERF-004: Only re-render Mermaid diagrams on theme change instead of full renderMarkdown() // CSS custom properties handle all other theme transitions automatically. // PERF-002: Guard mermaid re-render — skip if not loaded yet if (typeof mermaid !== 'undefined') { initMermaid(true); // Force re-init with new theme try { const mermaidNodes = markdownPreview.querySelectorAll('.mermaid'); if (mermaidNodes.length > 0) { // Clear existing rendered Mermaid SVGs and re-render with new theme mermaidNodes.forEach(function(node) { // Restore original diagram code to prevent parsing already-rendered SVG as source const originalCode = node.getAttribute('data-original-code'); if (originalCode) { const decodedCode = decodeURIComponent(originalCode); const escapedCode = decodedCode .replace(/&/g, "&") .replace(//g, ">"); node.innerHTML = escapedCode; } node.removeAttribute('data-processed'); const container = node.closest('.mermaid-container'); if (container) { container.classList.add('is-loading'); const oldToolbar = container.querySelector('.mermaid-toolbar'); if (oldToolbar) oldToolbar.remove(); } }); Promise.resolve(mermaid.init(undefined, mermaidNodes)) .then(function() { markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) { c.classList.remove('is-loading'); }); addMermaidToolbars(); }) .catch(function(e) { console.warn('Mermaid theme re-render failed:', e); markdownPreview.querySelectorAll('.mermaid-container.is-loading').forEach(function(c) { c.classList.remove('is-loading'); }); }); } } catch (e) { console.warn('Mermaid theme re-render failed:', e); } } }); async function nativeSaveMarkdown() { try { const content = markdownEditor.value; const result = await Neutralino.os.showSaveDialog("Save Markdown File", { filters: [ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, { name: "All files (*.*)", extensions: ["*"] } ] }); if (result) { await Neutralino.filesystem.writeFile(result, content); const fileName = result.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); const activeTab = tabs.find(function(t) { return t.id === activeTabId; }); if (activeTab) { activeTab.title = fileName; activeTab.content = content; saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); } } } catch (e) { console.error("Native save failed:", e); alert("Native save failed: " + e.message); } } async function nativeSaveHtml(htmlContent) { try { const result = await Neutralino.os.showSaveDialog("Save HTML document", { filters: [ { name: "HTML documents (*.html)", extensions: ["html", "htm"] } ] }); if (result) { await Neutralino.filesystem.writeFile(result, htmlContent); } } catch (e) { console.error("Native HTML save failed:", e); alert("Native HTML save failed: " + e.message); } } async function nativeImportMarkdown() { try { const result = await Neutralino.os.showOpenDialog("Open Markdown file", { filters: [ { name: "Markdown files (*.md)", extensions: ["md", "markdown"] }, { name: "All files (*.*)", extensions: ["*"] } ], multiSelections: true }); if (result && result.length > 0) { for (const filePath of result) { const content = await Neutralino.filesystem.readFile(filePath); const fileName = filePath.split(/[/\\]/).pop().replace(/\.(md|markdown)$/i, ""); newTab(content, fileName); } } } catch (e) { console.error("Native import failed:", e); alert("Native import failed: " + e.message); } } if (importFromFileButton) { importFromFileButton.addEventListener("click", function (e) { e.preventDefault(); if (typeof Neutralino !== 'undefined') { nativeImportMarkdown(); } else { fileInput.click(); } }); } if (importFromGithubButton) { importFromGithubButton.addEventListener("click", function (e) { e.preventDefault(); openGitHubImportModal(); }); } if (githubImportSubmitBtn) { githubImportSubmitBtn.addEventListener("click", handleGitHubImportSubmit); } if (githubImportCancelBtn) { githubImportCancelBtn.addEventListener("click", closeGitHubImportModal); } const handleGitHubImportInputKeydown = function(e) { if (e.key === "Enter") { e.preventDefault(); handleGitHubImportSubmit(); } else if (e.key === "Escape") { closeGitHubImportModal(); } }; if (githubImportUrlInput) { githubImportUrlInput.addEventListener("keydown", handleGitHubImportInputKeydown); } if (githubImportFileSelect) { githubImportFileSelect.addEventListener("keydown", handleGitHubImportInputKeydown); } if (githubImportSelectAllBtn) { githubImportSelectAllBtn.addEventListener("click", function() { const allPaths = availableGitHubImportPaths.slice(); const shouldSelectAll = selectedGitHubImportPaths.size !== allPaths.length; setGitHubSelectedPaths(shouldSelectAll ? allPaths : []); }); } fileInput.addEventListener("change", function (e) { const file = e.target.files[0]; if (file) { importMarkdownFile(file); } this.value = ""; }); exportMd.addEventListener("click", function () { if (typeof Neutralino !== 'undefined') { nativeSaveMarkdown(); return; } try { const blob = new Blob([markdownEditor.value], { type: "text/markdown;charset=utf-8", }); saveAs(blob, "document.md"); } catch (e) { console.error("Export failed:", e); alert("Export failed: " + e.message); } }); exportHtml.addEventListener("click", function () { try { const { frontmatter, body } = parseFrontmatter(markdownEditor.value); const tableHtml = frontmatter ? renderFrontmatterTable(frontmatter) : ''; const referenceData = extractReferenceDefinitions(body); const html = tableHtml + marked.parse(referenceData.cleanedMarkdown); const sanitizedHtml = DOMPurify.sanitize(html, { 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 tempContainer = document.createElement("div"); tempContainer.innerHTML = sanitizedHtml; applyReferencePreviewLinks(tempContainer, referenceData.definitions); enhanceGitHubAlerts(tempContainer); const enhancedHtml = tempContainer.innerHTML; const isDarkTheme = document.documentElement.getAttribute("data-theme") === "dark"; const cssTheme = isDarkTheme ? "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown-dark.min.css" : "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.3.0/github-markdown.min.css"; const fullHtml = ` Markdown Export
    ${enhancedHtml}
    `; const blob = new Blob([fullHtml], { type: "text/html;charset=utf-8" }); if (typeof Neutralino !== 'undefined') { nativeSaveHtml(fullHtml); } else { saveAs(blob, "document.html"); } } catch (e) { console.error("HTML export failed:", e); alert("HTML export failed: " + e.message); } }); // ============================================ // Page-Break Detection Functions (Story 1.1) // ============================================ // Page configuration constants for A4 PDF export const PAGE_CONFIG = { a4Width: 210, // mm a4Height: 297, // mm margin: 15, // mm each side contentWidth: 180, // 210 - 30 (margins) contentHeight: 267, // 297 - 30 (margins) windowWidth: 1000, // html2canvas config scale: 2 // html2canvas scale factor }; const PDF_EXPORT_DEBUG = true; let activePdfExport = null; class PdfExportCancelledError extends Error { constructor() { super("PDF generation cancelled."); this.name = "PdfExportCancelledError"; } } function logPdfExportDebug(...args) { if (PDF_EXPORT_DEBUG) console.log(...args); } function throwIfPdfExportAborted(signal) { if (signal && signal.aborted) { throw new PdfExportCancelledError(); } } function runPdfAbortable(state, promise) { throwIfPdfExportAborted(state.signal); return new Promise((resolve, reject) => { const handleAbort = () => reject(new PdfExportCancelledError()); state.signal.addEventListener("abort", handleAbort, { once: true }); Promise.resolve(promise) .then(resolve, reject) .finally(() => { state.signal.removeEventListener("abort", handleAbort); }); }); } function formatPdfExportEta(ms) { if (!Number.isFinite(ms) || ms <= 0) return "Calculating..."; const seconds = Math.ceil(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainder = seconds % 60; return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; } function createPdfProgressState() { const abortController = new AbortController(); const overlay = document.createElement("div"); overlay.className = "pdf-progress-overlay"; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-labelledby", "pdf-progress-title"); overlay.innerHTML = `

    Generating PDF

    0%
    Current Step Preparing
    Estimated remaining Calculating...
    `; const state = { abortController, signal: abortController.signal, startedAt: performance.now(), overlay, fill: overlay.querySelector(".pdf-progress-fill"), percentText: overlay.querySelector(".pdf-progress-percent"), progressBar: overlay.querySelector(".pdf-progress-track"), stepText: overlay.querySelector(".pdf-progress-step"), etaText: overlay.querySelector(".pdf-progress-eta"), cancelButtons: overlay.querySelectorAll(".pdf-progress-cancel, .pdf-progress-cancel-icon"), triggerHtml: new Map(), tempElement: null, cleanedUp: false }; state.cancelButtons.forEach(button => { button.addEventListener("click", () => cancelPdfExport(state)); }); return state; } function updatePdfProgress(state, percent, step) { if (!state || state.cleanedUp) return; const nextPercent = Math.max(0, Math.min(100, Math.round(percent))); state.fill.style.width = `${nextPercent}%`; state.percentText.textContent = `${nextPercent}%`; state.progressBar.setAttribute("aria-valuenow", String(nextPercent)); state.stepText.textContent = step; const elapsed = performance.now() - state.startedAt; const eta = nextPercent > 5 && nextPercent < 100 ? (elapsed / nextPercent) * (100 - nextPercent) : 0; state.etaText.textContent = nextPercent >= 100 ? "Complete" : formatPdfExportEta(eta); } function setPdfExportTriggersBusy(state, busy) { const triggers = [exportPdf, mobileExportPdf].filter(Boolean); triggers.forEach((trigger, index) => { if (busy) { state.triggerHtml.set(trigger, trigger.innerHTML); trigger.innerHTML = index === 0 ? ' Generating...' : ' Generating PDF...'; trigger.classList.add("pdf-export-loading"); trigger.setAttribute("aria-disabled", "true"); trigger.disabled = true; } else { if (state.triggerHtml.has(trigger)) { trigger.innerHTML = state.triggerHtml.get(trigger); } trigger.classList.remove("pdf-export-loading"); trigger.removeAttribute("aria-disabled"); trigger.disabled = false; } }); } function cleanupPdfExport(state) { if (!state || state.cleanedUp) return; state.cleanedUp = true; if (state.tempElement && state.tempElement.parentNode) { if (window.keepTempElementForAudit) { window.auditedTempElement = state.tempElement; console.log("Skipped tempElement removal for audit"); } else { state.tempElement.parentNode.removeChild(state.tempElement); } } if (state.overlay && state.overlay.parentNode) { state.overlay.parentNode.removeChild(state.overlay); } setPdfExportTriggersBusy(state, false); if (activePdfExport === state) { activePdfExport = null; } } function cancelPdfExport(state) { if (!state || state.signal.aborted) return; state.abortController.abort(); cleanupPdfExport(state); } async function waitForPdfFrame(state) { throwIfPdfExportAborted(state.signal); await new Promise(resolve => requestAnimationFrame(resolve)); throwIfPdfExportAborted(state.signal); } function markdownLikelyContainsMath(markdown) { return /(^|[^\\])\$\$|\\\[|\\\(|(^|[^\\])\$[^$\n]+\$/.test(markdown); } function choosePdfCanvasScale(element) { const pixelArea = element.offsetWidth * element.scrollHeight; if (pixelArea > 14000000) return 1.25; if (pixelArea > 8000000) return 1.5; return PAGE_CONFIG.scale; } function readPixelStyle(element, propertyName) { const value = window.getComputedStyle(element).getPropertyValue(propertyName); return parseFloat(value) || 0; } function fitExportElementToContent(element) { if (!element) return false; const overflow = element.scrollWidth - element.clientWidth; if (overflow <= 1) return false; const paddingLeft = readPixelStyle(element, 'padding-left'); const paddingRight = readPixelStyle(element, 'padding-right'); const borderLeft = readPixelStyle(element, 'border-left-width'); const borderRight = readPixelStyle(element, 'border-right-width'); const boxSizing = window.getComputedStyle(element).boxSizing; const requiredWidth = boxSizing === 'border-box' ? Math.ceil(element.scrollWidth + borderLeft + borderRight) : Math.ceil(element.scrollWidth - paddingLeft - paddingRight); element.style.width = `${requiredWidth}px`; return true; } /** * Task 1: Identifies all graphic elements that may need page-break handling * @param {HTMLElement} container - The container element to search within * @returns {Array} Array of {element, type} objects */ function identifyGraphicElements(container) { const graphics = []; // Query all targeting elements in precise DOM layout flow order container.querySelectorAll('img, svg, pre, table, p, li, h1, h2, h3, h4, h5, h6, blockquote, hr, .math-block, mjx-container[display="true"]').forEach(el => { const tag = el.tagName.toLowerCase(); // Skip any elements nested inside blockquotes to treat blockquotes as atomic containers if (el.parentElement && el.parentElement.closest('blockquote')) { return; } // Skip any elements nested inside list items that contain block children (treat list items as atomic) if (el.parentElement) { const liAncestor = el.parentElement.closest('li'); if (liAncestor) { const hasBlockChildren = liAncestor.querySelector('p, blockquote, pre, table, ul, ol') !== null; if (hasBlockChildren) { return; } } } let type = ''; if (tag === 'img') type = 'img'; else if (tag === 'svg') { if (!el.closest('mjx-container, .math-block')) { type = 'svg'; } } else if (tag === 'pre') type = 'pre'; else if (tag === 'table') type = 'table'; else if (tag === 'hr') type = 'hr'; else if (tag === 'blockquote') { type = 'blockquote'; } else if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) { type = 'text'; } else if (tag === 'li') { // Treat list items with block children as atomic containers, otherwise treat as text const hasBlockChildren = el.querySelector('p, blockquote, pre, table, ul, ol') !== null; if (hasBlockChildren) { type = 'li'; } else { type = 'text'; } } else if (el.classList.contains('math-block') || tag === 'mjx-container') { type = 'math'; } if (type) { graphics.push({ element: el, type: type }); } }); return graphics; } /** * Calculates the computed line-height of a text element, defaulting based on tag if "normal" * @param {HTMLElement} element * @returns {number} The line-height in pixels */ function getElementLineHeight(element) { const style = window.getComputedStyle(element); const fontSize = parseFloat(style.fontSize) || 14; let lineHeight = parseFloat(style.lineHeight); if (isNaN(lineHeight)) { const tag = element.tagName.toLowerCase(); if (tag.startsWith('h')) { lineHeight = fontSize * 1.25; } else { lineHeight = fontSize * 1.5; } } else if (lineHeight < 10) { // Handle unitless line-height (e.g. "1.5") lineHeight = lineHeight * fontSize; } return lineHeight; } /** * Calculates the shift needed to align a split text element's lines with the page boundary * @param {Object} item - The split element item * @param {Array} pageBoundaries - Page break positions * @returns {number} The shift in pixels */ function calculateTextElementShift(item, pageBoundaries) { const boundaryY = pageBoundaries[item.splitPageIndex]; if (boundaryY === undefined) return 0; const element = item.element; const style = window.getComputedStyle(element); const tag = element.tagName.toLowerCase(); const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); // Safety buffer (in pixels) to ensure text rendering, glyph ascenders, // and sub-pixel anti-aliasing are pushed cleanly past the slicing boundary. const SAFETY_BUFFER = 4; // Headings should never be split. Push the entire heading to the next page. if (isHeading) { return (boundaryY - item.top) + SAFETY_BUFFER; } const paddingTop = parseFloat(style.paddingTop) || 0; const borderTop = parseFloat(style.borderTopWidth) || 0; const paddingBottom = parseFloat(style.paddingBottom) || 0; const borderBottom = parseFloat(style.borderBottomWidth) || 0; const lh = getElementLineHeight(element); const contentTop = item.top + paddingTop + borderTop; const contentHeight = item.height - paddingTop - paddingBottom - borderTop - borderBottom; if (contentHeight <= 0) return 0; const numLines = Math.max(1, Math.round(contentHeight / lh)); for (let i = 0; i < numLines; i++) { const lineTop = contentTop + i * lh; const lineBottom = contentTop + (i + 1) * lh; // Check if this line is split by boundaryY (using a small tolerance to prevent float vibrations) if (lineTop < boundaryY - 0.5 && lineBottom > boundaryY + 0.5) { // Calculate shift to align the line's top with boundaryY, plus a safety buffer return (boundaryY - lineTop) + SAFETY_BUFFER; } } // Fallback: If a short paragraph/list item is split across pages but no single line is cut // (e.g. boundary falls exactly in padding/margin), push the whole element. if (item.height <= lh * 3) { return (boundaryY - item.top) + SAFETY_BUFFER; } return 0; } /** * Task 2: Calculates element positions relative to the container * @param {Array} elements - Array of {element, type} objects * @param {HTMLElement} container - The container element * @returns {Array} Array with position data added */ function calculateElementPositions(elements, container) { const containerRect = container.getBoundingClientRect(); return elements.map(item => { const rect = item.element.getBoundingClientRect(); const top = rect.top - containerRect.top; const height = rect.height; const bottom = top + height; return { element: item.element, type: item.type, top: top, height: height, bottom: bottom }; }); } /** * Task 3: Calculates page boundary positions * @param {number} totalHeight - Total height of content in pixels * @param {number} elementWidth - Actual width of the rendered element in pixels * @param {Object} pageConfig - Page configuration object * @returns {Array} Array of y-coordinates where pages end */ function calculatePageBoundaries(totalHeight, elementWidth, pageConfig) { const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; const pageHeightPx = elementWidth * aspectRatio; const boundaries = []; let y = pageHeightPx; while (y < totalHeight) { boundaries.push(y); y += pageHeightPx; } return { boundaries, pageHeightPx }; } /** * Task 4: Detects which elements would be split across page boundaries * @param {Array} elements - Array of elements with position data * @param {Array} pageBoundaries - Array of page break y-coordinates * @returns {Array} Array of split elements with additional split info */ function detectSplitElements(elements, pageBoundaries) { if (!elements || elements.length === 0) return []; if (!pageBoundaries || pageBoundaries.length === 0) return []; const splitElements = []; for (const item of elements) { let startPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.top >= pageBoundaries[i]) { startPage = i + 1; } else { break; } } let endPage = 0; for (let i = 0; i < pageBoundaries.length; i++) { if (item.bottom > pageBoundaries[i]) { endPage = i + 1; } else { break; } } if (endPage > startPage) { const boundaryY = pageBoundaries[startPage] || pageBoundaries[0]; const overflowAmount = item.bottom - boundaryY; splitElements.push({ element: item.element, type: item.type, top: item.top, height: item.height, splitPageIndex: startPage, overflowAmount: overflowAmount }); } } return splitElements; } /** * Task 5: Main entry point for analyzing graphics for page breaks * @param {HTMLElement} tempElement - The rendered content container * @returns {Object} Analysis result with totalElements, splitElements, pageCount */ function analyzeGraphicsForPageBreaks(tempElement, signal) { try { throwIfPdfExportAborted(signal); const graphics = identifyGraphicElements(tempElement); const elementsWithPositions = calculateElementPositions(graphics, tempElement); throwIfPdfExportAborted(signal); const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); const elementWidth = tempElement.offsetWidth; const { boundaries: pageBoundaries, pageHeightPx } = calculatePageBoundaries( totalHeight, elementWidth, PAGE_CONFIG ); const splitElements = detectSplitElements(elementsWithPositions, pageBoundaries); const pageCount = pageBoundaries.length + 1; return { totalElements: graphics.length, splitElements: splitElements, pageCount: pageCount, pageBoundaries: pageBoundaries, pageHeightPx: pageHeightPx }; } catch (error) { if (error instanceof PdfExportCancelledError) throw error; console.error('Page-break analysis failed:', error); return { totalElements: 0, splitElements: [], pageCount: 1, pageBoundaries: [], pageHeightPx: 0 }; } } // ============================================ // End Page-Break Detection Functions // ============================================ // ============================================ // Page-Break Insertion Functions (Story 1.2) // ============================================ /** * Resets temporary styles applied to graphics elements back to their original state. * This is called at the start of each layout iteration in the cascade loop. * @param {HTMLElement} container - The container element to process */ function resetGraphicsStyles(container) { // Remove all dynamically inserted page-break spacers container.querySelectorAll('.pdf-page-break-spacer').forEach(el => el.remove()); container.querySelectorAll('[data-pdf-original-margin-top]').forEach(el => { el.style.marginTop = el.dataset.pdfOriginalMarginTop; el.removeAttribute('data-pdf-original-margin-top'); }); container.querySelectorAll('[data-pdf-original-margin-bottom]').forEach(el => { el.style.marginBottom = el.dataset.pdfOriginalMarginBottom; el.removeAttribute('data-pdf-original-margin-bottom'); }); container.querySelectorAll('[data-pdf-original-transform]').forEach(el => { el.style.transform = el.dataset.pdfOriginalTransform; el.style.transformOrigin = ''; el.removeAttribute('data-pdf-original-transform'); }); container.querySelectorAll('[data-pdf-original-width]').forEach(el => { el.style.width = el.dataset.pdfOriginalWidth; el.removeAttribute('data-pdf-original-width'); }); container.querySelectorAll('[data-pdf-original-height]').forEach(el => { el.style.height = el.dataset.pdfOriginalHeight; el.removeAttribute('data-pdf-original-height'); }); container.querySelectorAll('[data-pdf-original-max-width]').forEach(el => { el.style.maxWidth = el.dataset.pdfOriginalMaxWidth; el.removeAttribute('data-pdf-original-max-width'); }); container.querySelectorAll('[data-pdf-original-font-size]').forEach(el => { el.style.fontSize = el.dataset.pdfOriginalFontSize; el.removeAttribute('data-pdf-original-font-size'); }); container.querySelectorAll('[data-pdf-original-overflow]').forEach(el => { el.style.overflow = el.dataset.pdfOriginalOverflow; el.removeAttribute('data-pdf-original-overflow'); }); } function mergeSplitTables(container) { const groupIds = new Set(); container.querySelectorAll('table[data-split-group-id]').forEach(t => { if (t.dataset.splitGroupId) { groupIds.add(t.dataset.splitGroupId); } }); for (const groupId of groupIds) { const originalTable = container.querySelector(`table[data-split-group-id="${groupId}"]:not([data-split-part="true"])`); if (!originalTable) continue; const parts = Array.from(container.querySelectorAll(`table[data-split-group-id="${groupId}"][data-split-part="true"]`)); const tbody = originalTable.tBodies[0] || originalTable.querySelector('tbody') || originalTable; for (const part of parts) { const partTbody = part.tBodies[0] || part.querySelector('tbody') || part; const rows = Array.from(partTbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); for (const row of rows) { tbody.appendChild(row); } part.remove(); } const spacers = Array.from(container.querySelectorAll(`div[data-split-group-id="${groupId}"][data-split-spacer="true"]`)); for (const spacer of spacers) { spacer.remove(); } originalTable.removeAttribute('data-split-group-id'); } } function splitTables(container, pageHeightPx) { mergeSplitTables(container); const tables = Array.from(container.querySelectorAll('table')); let groupCounter = 0; for (const table of tables) { if (table.dataset.splitPart === "true") continue; const tableRect = table.getBoundingClientRect(); if (tableRect.height <= pageHeightPx) continue; const tbody = table.tBodies[0] || table.querySelector('tbody'); if (!tbody) continue; const rows = Array.from(tbody.children).filter(child => child.tagName.toLowerCase() === 'tr'); if (rows.length === 0) continue; const groupId = `table-group-${groupCounter++}`; table.dataset.splitGroupId = groupId; const containerRect = container.getBoundingClientRect(); const rowPositions = rows.map(row => { const rect = row.getBoundingClientRect(); return { row: row, top: rect.top - containerRect.top, bottom: rect.bottom - containerRect.top, height: rect.height }; }); let currentTable = table; let currentTbody = tbody; let accumulatedShift = 0; for (let i = 0; i < rowPositions.length; i++) { const pos = rowPositions[i]; const shiftedTop = pos.top + accumulatedShift; const shiftedBottom = pos.bottom + accumulatedShift; const currentPageIndex = Math.floor(shiftedTop / pageHeightPx); const nextPageBoundary = (currentPageIndex + 1) * pageHeightPx; if (shiftedBottom > nextPageBoundary) { const spacerHeight = nextPageBoundary - shiftedTop; const originalThead = table.querySelector('thead'); const theadHeight = originalThead ? originalThead.getBoundingClientRect().height : 0; accumulatedShift += spacerHeight + theadHeight; const nextTable = table.cloneNode(false); nextTable.removeAttribute('id'); nextTable.dataset.splitGroupId = groupId; nextTable.dataset.splitPart = "true"; if (originalThead) nextTable.appendChild(originalThead.cloneNode(true)); const nextTbody = document.createElement('tbody'); nextTable.appendChild(nextTbody); const spacer = document.createElement('div'); spacer.className = 'table-page-break-spacer'; spacer.dataset.splitGroupId = groupId; spacer.dataset.splitSpacer = "true"; spacer.style.height = `${spacerHeight}px`; spacer.style.margin = '0'; spacer.style.padding = '0'; spacer.style.border = 'none'; currentTable.parentNode.insertBefore(spacer, currentTable.nextSibling); spacer.parentNode.insertBefore(nextTable, spacer.nextSibling); currentTable = nextTable; currentTbody = nextTbody; } if (currentTable !== table) currentTbody.appendChild(pos.row); } } } function applyPageBreaksWithCascade(tempElement, pageConfig, maxIterations = 10, signal) { let iteration = 0; let analysis; const elementWidth = tempElement.offsetWidth; const aspectRatio = pageConfig.contentHeight / pageConfig.contentWidth; const pageHeightPx = elementWidth * aspectRatio; const lastAdjustments = new Map(); // Store {margin, scale} for each element do { throwIfPdfExportAborted(signal); // Reset graphics element styles applied in previous iterations resetGraphicsStyles(tempElement); // Split tables at page breaks dynamically using calculated pageHeightPx splitTables(tempElement, pageHeightPx); // We must get ALL target elements in document order const graphics = identifyGraphicElements(tempElement); const elementsWithPositions = calculateElementPositions(graphics, tempElement); // Get page boundaries based on current height const totalHeight = Math.ceil(tempElement.getBoundingClientRect().height); const { boundaries: pageBoundaries, pageHeightPx: pageHeightPxFromAnalysis } = calculatePageBoundaries( totalHeight, elementWidth, pageConfig ); let adjustmentsMade = false; let accumulatedShift = 0; const currentIterationAdjustments = new Map(); for (const item of elementsWithPositions) { throwIfPdfExportAborted(signal); const currentTop = item.top + accumulatedShift; const currentBottom = currentTop + item.height; let targetMargin = 0; let targetScale = 1.0; // 1. Heading Keep-With-Next Rule (must run for all headings regardless of split) const tag = item.element.tagName.toLowerCase(); const isHeading = item.type === 'text' && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag); if (isHeading) { let nextBoundaryY = null; for (const boundary of pageBoundaries) { if (currentTop < boundary) { nextBoundaryY = boundary; break; } } if (nextBoundaryY !== null) { const distanceToBoundary = nextBoundaryY - currentTop; if (distanceToBoundary < 70) { targetMargin = distanceToBoundary + 4; // Push heading entirely to next page } } } // 2. If not already pushed by Keep-With-Next, perform standard page-split calculations if (targetMargin === 0) { // Check if this element crosses any page boundary or starts extremely close to it (sub-pixel safety) let splitPageIndex = -1; for (let i = 0; i < pageBoundaries.length; i++) { if (currentTop < pageBoundaries[i] + 12 && currentBottom > pageBoundaries[i]) { splitPageIndex = i; break; } } if (splitPageIndex !== -1) { const boundaryY = pageBoundaries[splitPageIndex]; const remainingSpace = boundaryY - currentTop; if (item.type === 'text') { const shiftedItem = { ...item, top: currentTop, splitPageIndex: splitPageIndex }; const shift = calculateTextElementShift(shiftedItem, pageBoundaries); if (shift > 0.5) { targetMargin = shift; } } else { // Graphic element splitting (with larger buffer to ensure complete clearance) const buffer = 15; const scaleNeeded = (remainingSpace - buffer) / item.height; const remainingSpacePercent = remainingSpace / pageHeightPxFromAnalysis; const isTextContainer = ['blockquote', 'li', 'table', 'pre', 'math'].includes(item.type); // Fit on current page by scaling if it's an image/svg and space/scale are acceptable. // Otherwise, always push text/block containers to next page to prevent transform-scaling bugs. if (!isTextContainer && remainingSpacePercent >= 0.20 && scaleNeeded >= 0.6) { targetScale = Math.min(1.0, scaleNeeded); } else { // Push to next page const marginNeeded = boundaryY - currentTop + buffer; targetMargin = marginNeeded; // Check if it fits the next page height after being pushed (Rule 3 Case C) const newTop = currentTop + marginNeeded; const newBottom = newTop + item.height; const nextBoundaryY = pageBoundaries[splitPageIndex + 1] || (boundaryY + pageHeightPxFromAnalysis); if (newBottom > nextBoundaryY) { const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); } } } } else { // Element is not split. But graphic elements taller than a page must still scale to fit! if (item.type !== 'text' && item.height > pageHeightPxFromAnalysis) { const scaleToFitPage = (pageHeightPxFromAnalysis - 20) / item.height; targetScale = Math.max(0.5, Math.min(1.0, scaleToFitPage)); } } } // Check if this calculated adjustment is different from the previous iteration const prevAdjustment = lastAdjustments.get(item.element) || { margin: 0, scale: 1.0 }; if (Math.abs(targetMargin - prevAdjustment.margin) > 0.1 || Math.abs(targetScale - prevAdjustment.scale) > 0.001) { adjustmentsMade = true; } currentIterationAdjustments.set(item.element, { margin: targetMargin, scale: targetScale }); // Apply style adjustments to the DOM if (targetMargin > 0) { let targetElement = item.element; // Redirect inline image or svg margins to their parent block element if nested if (item.type === 'svg' && item.element.parentElement) { const parent = item.element.parentElement; if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { targetElement = parent; } else { targetElement = parent; } } else if (item.type === 'img' && item.element.parentElement) { const parent = item.element.parentElement; if (item.element.classList.contains('mermaid-img')) { if (parent.parentElement && parent.parentElement.classList.contains('mermaid-container')) { targetElement = parent.parentElement; } else { targetElement = parent; } } else if (['p', 'li', 'blockquote'].includes(parent.tagName.toLowerCase())) { targetElement = parent; } } // If target is a list item, apply marginTop directly to avoid invalid HTML / collapsed spacers if (targetElement.tagName.toLowerCase() === 'li') { if (!targetElement.dataset.hasOwnProperty('pdfOriginalMarginTop')) { targetElement.dataset.pdfOriginalMarginTop = targetElement.style.marginTop || ''; } targetElement.style.marginTop = `${targetMargin}px`; } else { // Create a physical spacer element to avoid margin collapse issues entirely const spacer = document.createElement('div'); spacer.className = 'pdf-page-break-spacer'; spacer.style.height = `${targetMargin}px`; spacer.style.margin = '0'; spacer.style.padding = '0'; spacer.style.border = 'none'; spacer.style.display = 'block'; targetElement.parentNode.insertBefore(spacer, targetElement); } accumulatedShift += targetMargin; } if (targetScale < 1.0) { applyGraphicScaling(item.element, targetScale, item.type); const heightSaved = item.height * (1.0 - targetScale); accumulatedShift -= heightSaved; } } // Copy current adjustments to lastAdjustments lastAdjustments.clear(); for (const [el, adj] of currentIterationAdjustments) { lastAdjustments.set(el, adj); } if (!adjustmentsMade) { break; } iteration++; } while (iteration < maxIterations); if (iteration >= maxIterations) { console.warn('Page-break stabilization reached max iterations:', maxIterations); } // Return the final page boundaries and height analysis for the export flow analysis = analyzeGraphicsForPageBreaks(tempElement, signal); logPdfExportDebug('Page-break cascade complete:', { iterations: iteration, finalSplitCount: analysis.splitElements.length }); return analysis; } // ============================================ // End Page-Break Insertion Functions // ============================================ // ============================================ // Oversized Graphics Scaling Functions (Story 1.3) // ============================================ const MIN_SCALE_FACTOR = 0.5; function calculateScaleFactor(elementHeight, availableHeight, buffer = 5) { const targetHeight = availableHeight - buffer; let scaleFactor = targetHeight / elementHeight; let wasClampedToMin = false; if (scaleFactor < MIN_SCALE_FACTOR) { console.warn( `Warning: Large graphic requires ${(scaleFactor * 100).toFixed(0)}% scaling. ` + `Clamping to minimum ${MIN_SCALE_FACTOR * 100}%. Content may be cut off.` ); scaleFactor = MIN_SCALE_FACTOR; wasClampedToMin = true; } return { scaleFactor, wasClampedToMin }; } function applyGraphicScaling(element, scaleFactor, elementType) { if (!element.dataset.hasOwnProperty('pdfOriginalTransform')) { element.dataset.pdfOriginalTransform = element.style.transform || ''; } if (!element.dataset.hasOwnProperty('pdfOriginalMarginBottom')) { element.dataset.pdfOriginalMarginBottom = element.style.marginBottom || ''; } if (elementType === 'svg' || elementType === 'img') { if (!element.dataset.hasOwnProperty('pdfOriginalWidth')) { element.dataset.pdfOriginalWidth = element.style.width || ''; } if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { element.dataset.pdfOriginalHeight = element.style.height || ''; } if (!element.dataset.hasOwnProperty('pdfOriginalMaxWidth')) { element.dataset.pdfOriginalMaxWidth = element.style.maxWidth || ''; } let origWidth = parseFloat(element.dataset.pdfOriginalClientWidth); let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); if (isNaN(origWidth) || isNaN(origHeight)) { origWidth = element.clientWidth || element.getBoundingClientRect().width; origHeight = element.clientHeight || element.getBoundingClientRect().height; element.dataset.pdfOriginalClientWidth = String(origWidth); element.dataset.pdfOriginalClientHeight = String(origHeight); } // Apply physical scale element.style.width = `${origWidth * scaleFactor}px`; element.style.height = `${origHeight * scaleFactor}px`; if (elementType === 'svg') { element.style.maxWidth = 'none'; } } else { // For pre, table, blockquote, math, li, etc. // Use transform: scale combined with physical height and overflow hidden to guarantee no native splits if (!element.dataset.hasOwnProperty('pdfOriginalHeight')) { element.dataset.pdfOriginalHeight = element.style.height || ''; } if (!element.dataset.hasOwnProperty('pdfOriginalOverflow')) { element.dataset.pdfOriginalOverflow = element.style.overflow || ''; } element.style.transform = `scale(${scaleFactor})`; element.style.transformOrigin = 'top left'; let origHeight = parseFloat(element.dataset.pdfOriginalClientHeight); if (isNaN(origHeight)) { origHeight = element.offsetHeight || element.getBoundingClientRect().height; element.dataset.pdfOriginalClientHeight = String(origHeight); } const scaledHeight = origHeight * scaleFactor; element.style.height = `${scaledHeight}px`; element.style.overflow = 'hidden'; } } function waitForAllImages(container) { const imgs = Array.from(container.querySelectorAll('img')); const promises = imgs.map(img => { if (img.complete) return Promise.resolve(); return new Promise(resolve => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }); }); return Promise.all(promises); } // ============================================ // End Oversized Graphics Scaling Functions // ============================================ exportPdf.addEventListener("click", async function (event) { event.preventDefault(); logPdfExportDebug("PDF export button clicked!"); if (activePdfExport) { logPdfExportDebug("PDF export already active, ignoring click"); return; } const progressState = createPdfProgressState(); activePdfExport = progressState; setPdfExportTriggersBusy(progressState, true); document.body.appendChild(progressState.overlay); updatePdfProgress(progressState, 3, "Starting"); progressState.overlay.querySelector(".pdf-progress-cancel")?.focus(); try { logPdfExportDebug("PDF export try block entered. typeof jspdf:", typeof jspdf, "typeof html2canvas:", typeof html2canvas); // PERF-002: Lazy-load PDF libraries on first export if (typeof jspdf === 'undefined' || typeof html2canvas === 'undefined') { logPdfExportDebug("Lazy loading PDF libraries started..."); updatePdfProgress(progressState, 8, "Loading PDF libraries"); await runPdfAbortable(progressState, Promise.all([ loadScript(CDN.jspdf).then(() => logPdfExportDebug("jspdf load callback fired")), loadScript(CDN.html2canvas).then(() => logPdfExportDebug("html2canvas load callback fired")) ])); logPdfExportDebug("Lazy loading PDF libraries resolved."); throwIfPdfExportAborted(progressState.signal); } logPdfExportDebug("Parsing markdown..."); updatePdfProgress(progressState, 15, "Parsing markdown"); await waitForPdfFrame(progressState); const markdown = markdownEditor.value; const html = marked.parse(markdown); const sanitizedHtml = DOMPurify.sanitize(html, { ADD_TAGS: ['mjx-container', 'svg', 'path', 'g', 'marker', 'defs', 'pattern', 'clipPath', 'input'], ADD_ATTR: ['id', 'class', 'style', 'align', 'viewBox', 'd', 'fill', 'stroke', 'transform', 'marker-end', 'marker-start', 'type', 'checked', 'disabled', 'data-original-code'], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i }); throwIfPdfExportAborted(progressState.signal); updatePdfProgress(progressState, 24, "Preparing document"); await waitForPdfFrame(progressState); const tempElement = document.createElement("div"); progressState.tempElement = tempElement; tempElement.className = "markdown-body pdf-export"; tempElement.innerHTML = sanitizedHtml; enhanceGitHubAlerts(tempElement); tempElement.style.padding = "0px"; tempElement.style.width = "210mm"; tempElement.style.margin = "0 auto"; tempElement.style.fontSize = "14px"; tempElement.style.position = "fixed"; tempElement.style.left = "-9999px"; tempElement.style.top = "0"; const currentTheme = document.documentElement.getAttribute("data-theme"); tempElement.style.backgroundColor = currentTheme === "dark" ? "#0d1117" : "#ffffff"; tempElement.style.color = currentTheme === "dark" ? "#c9d1d9" : "#24292e"; document.body.appendChild(tempElement); await waitForPdfFrame(progressState); const mermaidNodes = tempElement.querySelectorAll('.mermaid'); if (mermaidNodes.length > 0) { updatePdfProgress(progressState, 34, "Rendering diagrams"); try { if (typeof mermaid === 'undefined') { await runPdfAbortable(progressState, loadScript(CDN.mermaid)); } throwIfPdfExportAborted(progressState.signal); initMermaid(true); await runPdfAbortable(progressState, mermaid.init(undefined, mermaidNodes)); tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => { container.classList.remove('is-loading'); }); // Convert all rendered Mermaid SVGs inside tempElement to tags with data URI sources const compiledMermaids = tempElement.querySelectorAll('.mermaid-container'); compiledMermaids.forEach(container => { const svgElement = container.querySelector('svg'); if (svgElement) { const rect = svgElement.getBoundingClientRect(); const width = rect.width || svgElement.clientWidth || parseFloat(svgElement.getAttribute('width')) || 600; const height = rect.height || svgElement.clientHeight || parseFloat(svgElement.getAttribute('height')) || 400; const clonedSvg = svgElement.cloneNode(true); clonedSvg.setAttribute('width', width); clonedSvg.setAttribute('height', height); if (!clonedSvg.getAttribute('viewBox')) { clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); } clonedSvg.style.width = `${width}px`; clonedSvg.style.height = `${height}px`; const svgString = new XMLSerializer().serializeToString(clonedSvg); const svgBase64 = btoa(unescape(encodeURIComponent(svgString))); const img = document.createElement('img'); img.className = 'mermaid-img'; if (svgElement.id) img.id = svgElement.id + '-img'; img.src = 'data:image/svg+xml;base64,' + svgBase64; img.style.width = `${width}px`; img.style.height = `${height}px`; img.style.maxWidth = '100%'; img.style.display = 'block'; img.style.margin = '0 auto'; img.dataset.originalWidth = String(width); img.dataset.originalHeight = String(height); container.innerHTML = ''; container.appendChild(img); } }); } catch (mermaidError) { if (mermaidError instanceof PdfExportCancelledError) throw mermaidError; console.warn("Mermaid rendering issue:", mermaidError); tempElement.querySelectorAll('.mermaid-container.is-loading').forEach(container => { container.classList.remove('is-loading'); }); } throwIfPdfExportAborted(progressState.signal); await waitForPdfFrame(progressState); } if (window.MathJax && markdownLikelyContainsMath(markdown)) { updatePdfProgress(progressState, 44, "Rendering math"); try { await runPdfAbortable(progressState, MathJax.typesetPromise([tempElement])); } catch (mathJaxError) { if (mathJaxError instanceof PdfExportCancelledError) throw mathJaxError; console.warn("MathJax rendering issue:", mathJaxError); } throwIfPdfExportAborted(progressState.signal); // Hide MathJax assistive elements that cause duplicate text in PDF // These are screen reader elements that html2canvas captures as visible // Use multiple CSS properties to ensure html2canvas doesn't render them const assistiveElements = tempElement.querySelectorAll('mjx-assistive-mml'); assistiveElements.forEach(el => { el.style.display = 'none'; el.style.visibility = 'hidden'; el.style.position = 'absolute'; el.style.width = '0'; el.style.height = '0'; el.style.overflow = 'hidden'; el.remove(); // Remove entirely from DOM }); // Also hide any MathJax script elements that might contain source const mathScripts = tempElement.querySelectorAll('script[type*="math"], script[type*="tex"]'); mathScripts.forEach(el => el.remove()); } await waitForPdfFrame(progressState); fitExportElementToContent(tempElement); await waitForPdfFrame(progressState); // Await loading of all images and fonts (including converted Mermaid base64 images) before cascade sizing runs updatePdfProgress(progressState, 50, "Loading document assets"); await runPdfAbortable(progressState, Promise.all([ waitForAllImages(tempElement), document.fonts ? document.fonts.ready : Promise.resolve() ])); throwIfPdfExportAborted(progressState.signal); await waitForPdfFrame(progressState); // Analyze and apply page-breaks for graphics (Story 1.1 + 1.2) updatePdfProgress(progressState, 55, "Optimizing page breaks"); const pageBreakAnalysis = applyPageBreaksWithCascade(tempElement, PAGE_CONFIG, 10, progressState.signal); throwIfPdfExportAborted(progressState.signal); await waitForPdfFrame(progressState); const pdfOptions = { orientation: 'portrait', unit: 'mm', format: 'a4', compress: true, hotfixes: ["px_scaling"] }; const pdf = new jspdf.jsPDF(pdfOptions); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 15; const contentWidth = pageWidth - (margin * 2); const captureScale = choosePdfCanvasScale(tempElement); updatePdfProgress(progressState, 65, "Capturing document"); const canvas = await runPdfAbortable(progressState, html2canvas(tempElement, { scale: captureScale, useCORS: true, allowTaint: false, logging: false, windowWidth: Math.max(PAGE_CONFIG.windowWidth, Math.ceil(tempElement.getBoundingClientRect().width)), windowHeight: Math.ceil(tempElement.getBoundingClientRect().height) })); await waitForPdfFrame(progressState); throwIfPdfExportAborted(progressState.signal); console.log(`[PDF DEBUG] canvas.width = ${canvas.width}, canvas.height = ${canvas.height}`); console.log(`[PDF DEBUG] tempElement.offsetWidth = ${tempElement.offsetWidth}, rect.width = ${tempElement.getBoundingClientRect().width}`); const scaleFactor = canvas.width / contentWidth; console.log(`[PDF DEBUG] scaleFactor = ${scaleFactor}, PAGE_CONFIG.scale = ${PAGE_CONFIG.scale}, captureScale = ${captureScale}`); const imgHeight = canvas.height / scaleFactor; console.log(`[PDF DEBUG] imgHeight = ${imgHeight}, contentHeight = ${pageHeight - margin * 2}`); // Introduce a 0.5mm tolerance to prevent rounding errors from creating a trailing blank page const pagesCount = Math.ceil((imgHeight - 0.5) / (pageHeight - margin * 2)); console.log(`[PDF DEBUG] pagesCount = ${pagesCount}`); updatePdfProgress(progressState, 76, "Rendering pages"); for (let page = 0; page < pagesCount; page++) { throwIfPdfExportAborted(progressState.signal); const pageProgress = 76 + ((page + 1) / pagesCount) * 18; updatePdfProgress(progressState, pageProgress, `Rendering page ${page + 1} of ${pagesCount}`); if (page > 0) pdf.addPage(); const sourceY = page * (pageHeight - margin * 2) * scaleFactor; const sourceHeight = Math.min(canvas.height - sourceY, (pageHeight - margin * 2) * scaleFactor); const destHeight = sourceHeight / scaleFactor; const pageCanvas = document.createElement('canvas'); pageCanvas.width = canvas.width; pageCanvas.height = sourceHeight; const ctx = pageCanvas.getContext('2d'); ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceHeight, 0, 0, canvas.width, sourceHeight); const imgData = pageCanvas.toDataURL('image/png'); pdf.addImage(imgData, 'PNG', margin, margin, contentWidth, destHeight); await waitForPdfFrame(progressState); } throwIfPdfExportAborted(progressState.signal); updatePdfProgress(progressState, 98, "Preparing download"); pdf.save("document.pdf"); updatePdfProgress(progressState, 100, "Complete"); } catch (error) { if (error instanceof PdfExportCancelledError || progressState.signal.aborted) { console.info("PDF export cancelled"); } else { console.error("PDF export failed:", error); alert("PDF export failed: " + error.message); } } finally { cleanupPdfExport(progressState); } }); copyMarkdownButton.addEventListener("click", function () { try { const markdownText = markdownEditor.value; copyToClipboard(markdownText); } catch (e) { console.error("Copy failed:", e); alert("Failed to copy Markdown: " + e.message); } }); async function copyTextToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return; } const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand("copy"); document.body.removeChild(textArea); if (!successful) { throw new Error("Copy command was unsuccessful"); } } async function copyToClipboard(text) { try { await copyTextToClipboard(text); showCopiedMessage(); } catch (err) { console.error("Copy failed:", err); alert("Failed to copy HTML: " + err.message); } } function showCopiedMessage() { const originalText = copyMarkdownButton.innerHTML; copyMarkdownButton.innerHTML = ' Copied!'; setTimeout(() => { copyMarkdownButton.innerHTML = originalText; }, 2000); } // ============================================ // Share via URL (pako compression + base64url) // ============================================ const MAX_SHARE_URL_LENGTH = 32000; function encodeMarkdownForShare(text) { if (typeof pako === 'undefined') throw new Error('pako not loaded'); const compressed = pako.deflate(new TextEncoder().encode(text)); const chunkSize = 0x8000; let binary = ''; for (let i = 0; i < compressed.length; i += chunkSize) { binary += String.fromCharCode.apply(null, compressed.subarray(i, i + chunkSize)); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function decodeMarkdownFromShare(encoded) { if (typeof pako === 'undefined') throw new Error('pako not loaded'); const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); const binary = atob(base64); const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); return new TextDecoder().decode(pako.inflate(bytes)); } // ============================================ // Share Modal // ============================================ const shareModal = document.getElementById('share-modal'); const shareModalCloseX = document.getElementById('share-modal-close-icon'); const shareModalClose = document.getElementById('share-modal-close'); const shareUrlInput = document.getElementById('share-url-input'); const shareCopyBtn = document.getElementById('share-copy-btn'); const shareModeView = document.getElementById('share-mode-view'); const shareModeEdit = document.getElementById('share-mode-edit'); const shareCardView = document.getElementById('share-card-view'); const shareCardEdit = document.getElementById('share-card-edit'); function buildShareUrl(mode) { const markdownText = markdownEditor.value; let encoded; try { encoded = encodeMarkdownForShare(markdownText); } catch (e) { console.error('Share encoding failed:', e); return null; } const isLocal = window.location.origin.includes('localhost') || window.location.origin.startsWith('file://') || typeof Neutralino !== 'undefined'; const baseUrl = isLocal ? 'https://markdownviewer.pages.dev/' : window.location.origin + window.location.pathname; const base = baseUrl + '#share=' + encoded; return mode === 'edit' ? base + '&edit=1' : base; } function updateShareUrlField() { const mode = shareModeView.checked ? 'view' : 'edit'; const url = buildShareUrl(mode); if (!url) { shareUrlInput.value = 'Error generating link.'; shareCopyBtn.disabled = true; return; } const tooLarge = url.length > MAX_SHARE_URL_LENGTH; if (tooLarge) { shareUrlInput.value = 'Document too large to share via URL.'; shareCopyBtn.disabled = true; } else { shareUrlInput.value = url; shareCopyBtn.disabled = false; } } function openShareModal() { // PERF-002: Lazy-load pako on first share if (typeof pako === 'undefined') { loadScript(CDN.pako).then(function() { openShareModal(); }).catch(function(e) { console.error('Failed to load pako:', e); alert('Failed to load sharing library. Please check your internet connection.'); }); return; } // Reset to view-only by default each time shareModeView.checked = true; syncShareCardStyles(); updateShareUrlField(); shareModal.style.display = ''; requestAnimationFrame(() => { shareModal.classList.add('is-visible'); shareModal.setAttribute('aria-hidden', 'false'); }); } function closeShareModal() { shareModal.classList.remove('is-visible'); shareModal.setAttribute('aria-hidden', 'true'); shareModal.addEventListener('transitionend', function handler() { shareModal.style.display = 'none'; shareModal.removeEventListener('transitionend', handler); }); } function syncShareCardStyles() { if (shareModeView.checked) { shareCardView.classList.add('is-selected'); shareCardEdit.classList.remove('is-selected'); } else { shareCardEdit.classList.add('is-selected'); shareCardView.classList.remove('is-selected'); } } shareModeView.addEventListener('change', function () { syncShareCardStyles(); updateShareUrlField(); }); shareModeEdit.addEventListener('change', function () { syncShareCardStyles(); updateShareUrlField(); }); shareCopyBtn.addEventListener('click', function () { const url = shareUrlInput.value; if (!url || shareCopyBtn.disabled) return; function onCopied() { const orig = shareCopyBtn.innerHTML; shareCopyBtn.innerHTML = ''; setTimeout(() => { shareCopyBtn.innerHTML = orig; }, 2000); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(url).then(onCopied).catch(() => {}); } else { try { const tmp = document.createElement('textarea'); tmp.value = url; document.body.appendChild(tmp); tmp.select(); document.execCommand('copy'); document.body.removeChild(tmp); onCopied(); } catch (_) {} } }); shareModalCloseX.addEventListener('click', closeShareModal); shareModalClose.addEventListener('click', closeShareModal); shareModal.addEventListener('click', function (e) { if (e.target === shareModal) closeShareModal(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && shareModal.classList.contains('is-visible')) { closeShareModal(); } // Global Ctrl+F / Cmd+F interception if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFindReplaceModal(); const findInput = document.getElementById('find-replace-input'); if (findInput) { findInput.focus(); findInput.select(); } return; } // Global Ctrl+H / Cmd+H interception if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'h') { e.preventDefault(); openFindReplaceModal(); const replaceInput = document.getElementById('find-replace-with'); if (replaceInput) { replaceInput.focus(); replaceInput.select(); } return; } // Global Escape dismissal for find-replace panel if (e.key === 'Escape' && isFindModalOpen) { e.preventDefault(); closeFindReplaceModal(); return; } }); shareButton.addEventListener('click', openShareModal); mobileShareButton.addEventListener('click', openShareModal); function loadFromShareHash() { // PERF-002: Lazy-load pako when loading shared URL content if (typeof pako === 'undefined') { const hash = window.location.hash; if (!hash.startsWith('#share=')) return; loadScript(CDN.pako).then(function() { loadFromShareHash(); }).catch(function(e) { console.error('Failed to load pako for shared URL:', e); }); return; } const hash = window.location.hash; if (!hash.startsWith('#share=')) return; // Parse encoded content and optional &edit=1 flag. // Hash format: #share= or #share=&edit=1 const rest = hash.slice('#share='.length); const ampIdx = rest.indexOf('&'); const encoded = ampIdx === -1 ? rest : rest.slice(0, ampIdx); const params = ampIdx === -1 ? '' : rest.slice(ampIdx + 1); const isEdit = params.split('&').includes('edit=1'); if (!encoded) return; try { const decoded = decodeMarkdownFromShare(encoded); markdownEditor.value = decoded; renderMarkdown({ reason: 'document-load', showSkeleton: true }); saveCurrentTabState(); // Apply the correct view mode: edit=1 → split, default → preview only setViewMode(isEdit ? 'split' : 'preview'); } catch (e) { console.error("Failed to load shared content:", e); alert("The shared URL could not be decoded. It may be corrupted or incomplete."); } } loadFromShareHash(); // Full-window drag-and-drop: track nesting level for reliable enter/leave detection let dragDepth = 0; document.addEventListener("dragenter", function(e) { if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) { e.preventDefault(); dragDepth++; dragOverlay.classList.add("active"); dragOverlay.setAttribute("aria-hidden", "false"); } }, false); document.addEventListener("dragover", function(e) { if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) { e.preventDefault(); } }, false); document.addEventListener("dragleave", function(e) { if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes("Files")) { dragDepth--; if (dragDepth <= 0) { dragDepth = 0; dragOverlay.classList.remove("active"); dragOverlay.setAttribute("aria-hidden", "true"); } } }, false); document.addEventListener("drop", function(e) { e.preventDefault(); dragDepth = 0; dragOverlay.classList.remove("active"); dragOverlay.setAttribute("aria-hidden", "true"); handleDrop(e); }, false); function handleDrop(e) { const dt = e.dataTransfer; const files = dt.files; if (files.length) { const file = files[0]; const isMarkdownFile = file.type === "text/markdown" || /\.(md|markdown)$/i.test(file.name || ""); if (isMarkdownFile) { importMarkdownFile(file); } else { alert("Please upload a Markdown file (.md or .markdown)"); } } } document.addEventListener("keydown", function (e) { if (document.activeElement === markdownEditor) { const isCmdOrCtrl = e.ctrlKey || e.metaKey; if (isCmdOrCtrl && !e.shiftKey && e.key.toLowerCase() === 'z') { e.preventDefault(); executeUndo(); return; } else if ((isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === 'z') || (isCmdOrCtrl && e.key.toLowerCase() === 'y')) { e.preventDefault(); executeRedo(); return; } } if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); exportMd.click(); } if ((e.ctrlKey || e.metaKey) && e.key === "c") { const activeEl = document.activeElement; const isTextControl = activeEl && (activeEl.tagName === "TEXTAREA" || activeEl.tagName === "INPUT"); const hasSelection = window.getSelection && window.getSelection().toString().trim().length > 0; const editorHasSelection = markdownEditor.selectionStart !== markdownEditor.selectionEnd; if (!isTextControl && !hasSelection && !editorHasSelection) { e.preventDefault(); copyMarkdownButton.click(); } } // Story 1.2: Only allow sync toggle shortcut when in split view if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "s") { e.preventDefault(); if (currentViewMode === 'split') { toggleSyncScrolling(); } } const isDesktop = typeof Neutralino !== 'undefined'; // New tab (Ctrl+T on desktop, Alt+Shift+T on web/desktop) if ((isDesktop && (e.ctrlKey || e.metaKey) && e.key === "t") || (e.altKey && e.shiftKey && e.key.toLowerCase() === "t")) { e.preventDefault(); newTab(); } // Close tab (Ctrl+W on desktop, Alt+Shift+W on web/desktop) if ((isDesktop && (e.ctrlKey || e.metaKey) && e.key === "w") || (e.altKey && e.shiftKey && e.key.toLowerCase() === "w")) { e.preventDefault(); closeTab(activeTabId); } // Close Mermaid zoom modal with Escape if (e.key === "Escape") { closeTabMenus(); closeMermaidModal(); } }); document.getElementById('tab-reset-btn').addEventListener('click', function() { resetAllTabs(); }); // ======================================== // MERMAID DIAGRAM TOOLBAR // ======================================== /** * Serialises an SVG element to a data URL suitable for use as an image source. * Inline styles and dimensions are preserved so the PNG matches the rendered diagram. */ function svgToDataUrl(svgEl) { const clone = svgEl.cloneNode(true); // Ensure explicit width/height so the canvas has the right dimensions const bbox = svgEl.getBoundingClientRect(); if (!clone.getAttribute('width')) clone.setAttribute('width', Math.round(bbox.width)); if (!clone.getAttribute('height')) clone.setAttribute('height', Math.round(bbox.height)); const serialized = new XMLSerializer().serializeToString(clone); return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(serialized); } /** * Renders an SVG element onto a canvas and resolves with the canvas. */ function svgToCanvas(svgEl) { return new Promise((resolve, reject) => { const bbox = svgEl.getBoundingClientRect(); const scale = window.devicePixelRatio || 1; const width = Math.max(Math.round(bbox.width), 1); const height = Math.max(Math.round(bbox.height), 1); const canvas = document.createElement('canvas'); canvas.width = width * scale; canvas.height = height * scale; const ctx = canvas.getContext('2d'); ctx.scale(scale, scale); // Fill background matching current theme using the CSS variable value const bgColor = getComputedStyle(document.documentElement) .getPropertyValue('--bg-color').trim() || '#ffffff'; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, height); const img = new Image(); img.onload = () => { ctx.drawImage(img, 0, 0, width, height); resolve(canvas); }; img.onerror = reject; img.src = svgToDataUrl(svgEl); }); } /** Downloads the diagram in the given container as a PNG file. */ async function downloadMermaidPng(container, btn) { const svgEl = container.querySelector('svg'); if (!svgEl) return; const original = btn.innerHTML; btn.innerHTML = ''; try { const canvas = await svgToCanvas(svgEl); canvas.toBlob(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `diagram-${Date.now()}.png`; a.click(); URL.revokeObjectURL(url); btn.innerHTML = ''; setTimeout(() => { btn.innerHTML = original; }, 1500); }, 'image/png'); } catch (e) { console.error('Mermaid PNG export failed:', e); btn.innerHTML = original; } } /** Copies the diagram in the given container as a PNG image to the clipboard. */ async function copyMermaidImage(container, btn) { const svgEl = container.querySelector('svg'); if (!svgEl) return; const original = btn.innerHTML; btn.innerHTML = ''; try { const canvas = await svgToCanvas(svgEl); canvas.toBlob(async blob => { try { await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]); btn.innerHTML = ' Copied!'; } catch (clipErr) { console.error('Clipboard write failed:', clipErr); btn.innerHTML = ''; } setTimeout(() => { btn.innerHTML = original; }, 1800); }, 'image/png'); } catch (e) { console.error('Mermaid copy failed:', e); btn.innerHTML = original; } } /** Downloads the SVG source of a diagram. */ function downloadMermaidSvg(container, btn) { const svgEl = container.querySelector('svg'); if (!svgEl) return; const clone = svgEl.cloneNode(true); const serialized = new XMLSerializer().serializeToString(clone); const blob = new Blob([serialized], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `diagram-${Date.now()}.svg`; a.click(); URL.revokeObjectURL(url); const original = btn.innerHTML; btn.innerHTML = ''; setTimeout(() => { btn.innerHTML = original; }, 1500); } // ---- Zoom modal state ---- let modalZoomScale = 1; let modalPanX = 0; let modalPanY = 0; let modalIsDragging = false; let modalDragStart = { x: 0, y: 0 }; let modalCurrentSvgEl = null; const mermaidZoomModal = document.getElementById('mermaid-zoom-modal'); const mermaidModalDiagram = document.getElementById('mermaid-modal-diagram'); function applyModalTransform() { if (modalCurrentSvgEl) { modalCurrentSvgEl.style.transform = `translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoomScale})`; } } function closeMermaidModal() { if (!mermaidZoomModal.classList.contains('active')) return; mermaidZoomModal.classList.remove('active'); // PERF-007: Clear elements using textContent mermaidModalDiagram.textContent = ''; modalCurrentSvgEl = null; modalZoomScale = 1; modalPanX = 0; modalPanY = 0; } /** Opens the zoom modal with the SVG from the given container. */ function openMermaidZoomModal(container) { const svgEl = container.querySelector('svg'); if (!svgEl) return; // PERF-007: Clear elements using textContent mermaidModalDiagram.textContent = ''; modalZoomScale = 1; modalPanX = 0; modalPanY = 0; const svgClone = svgEl.cloneNode(true); // Remove fixed dimensions so it sizes naturally inside the modal svgClone.removeAttribute('width'); svgClone.removeAttribute('height'); svgClone.style.width = 'auto'; svgClone.style.height = 'auto'; svgClone.style.maxWidth = '80vw'; svgClone.style.maxHeight = '60vh'; svgClone.style.transformOrigin = 'center'; mermaidModalDiagram.appendChild(svgClone); modalCurrentSvgEl = svgClone; mermaidZoomModal.classList.add('active'); } // Modal close button document.getElementById('mermaid-modal-close').addEventListener('click', closeMermaidModal); // Click backdrop to close mermaidZoomModal.addEventListener('click', function(e) { if (e.target === mermaidZoomModal) closeMermaidModal(); }); // Zoom controls document.getElementById('mermaid-modal-zoom-in').addEventListener('click', () => { modalZoomScale = Math.min(modalZoomScale + 0.25, 10); applyModalTransform(); }); document.getElementById('mermaid-modal-zoom-out').addEventListener('click', () => { modalZoomScale = Math.max(modalZoomScale - 0.25, 0.1); applyModalTransform(); }); document.getElementById('mermaid-modal-zoom-reset').addEventListener('click', () => { modalZoomScale = 1; modalPanX = 0; modalPanY = 0; applyModalTransform(); }); // Mouse-wheel zoom inside modal mermaidModalDiagram.addEventListener('wheel', function(e) { e.preventDefault(); const delta = e.deltaY < 0 ? 0.15 : -0.15; modalZoomScale = Math.min(Math.max(modalZoomScale + delta, 0.1), 10); applyModalTransform(); }, { passive: false }); // Drag to pan inside modal mermaidModalDiagram.addEventListener('mousedown', function(e) { modalIsDragging = true; modalDragStart = { x: e.clientX - modalPanX, y: e.clientY - modalPanY }; mermaidModalDiagram.classList.add('dragging'); }); document.addEventListener('mousemove', function(e) { if (!modalIsDragging) return; modalPanX = e.clientX - modalDragStart.x; modalPanY = e.clientY - modalDragStart.y; applyModalTransform(); }); document.addEventListener('mouseup', function() { if (modalIsDragging) { modalIsDragging = false; mermaidModalDiagram.classList.remove('dragging'); } }); // Modal download buttons (operate on the currently displayed SVG) document.getElementById('mermaid-modal-download-png').addEventListener('click', async function() { if (!modalCurrentSvgEl) return; const btn = this; const original = btn.innerHTML; btn.innerHTML = ''; try { // Use the original SVG (with dimensions) for proper PNG rendering const canvas = await svgToCanvas(modalCurrentSvgEl); canvas.toBlob(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `diagram-${Date.now()}.png`; a.click(); URL.revokeObjectURL(url); btn.innerHTML = ''; setTimeout(() => { btn.innerHTML = original; }, 1500); }, 'image/png'); } catch (e) { console.error('Modal PNG export failed:', e); btn.innerHTML = original; } }); document.getElementById('mermaid-modal-copy').addEventListener('click', async function() { if (!modalCurrentSvgEl) return; const btn = this; const original = btn.innerHTML; btn.innerHTML = ''; try { const canvas = await svgToCanvas(modalCurrentSvgEl); canvas.toBlob(async blob => { try { await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }) ]); btn.innerHTML = ' Copied!'; } catch (clipErr) { console.error('Clipboard write failed:', clipErr); btn.innerHTML = ''; } setTimeout(() => { btn.innerHTML = original; }, 1800); }, 'image/png'); } catch (e) { console.error('Modal copy failed:', e); btn.innerHTML = original; } }); document.getElementById('mermaid-modal-download-svg').addEventListener('click', function() { if (!modalCurrentSvgEl) return; const serialized = new XMLSerializer().serializeToString(modalCurrentSvgEl); const blob = new Blob([serialized], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `diagram-${Date.now()}.svg`; a.click(); URL.revokeObjectURL(url); }); /** * Adds the hover toolbar to every rendered Mermaid container. * Safe to call multiple times – existing toolbars are not duplicated. */ function addMermaidToolbars() { markdownPreview.querySelectorAll('.mermaid-container').forEach(container => { if (container.querySelector('.mermaid-toolbar')) return; // already added const svgEl = container.querySelector('svg'); if (!svgEl) return; // diagram not yet rendered const toolbar = document.createElement('div'); toolbar.className = 'mermaid-toolbar'; toolbar.setAttribute('aria-label', 'Diagram actions'); const btnZoom = document.createElement('button'); btnZoom.className = 'mermaid-toolbar-btn'; btnZoom.title = 'Zoom diagram'; btnZoom.setAttribute('aria-label', 'Zoom diagram'); btnZoom.innerHTML = ''; btnZoom.addEventListener('click', () => openMermaidZoomModal(container)); const btnPng = document.createElement('button'); btnPng.className = 'mermaid-toolbar-btn'; btnPng.title = 'Download PNG'; btnPng.setAttribute('aria-label', 'Download PNG'); btnPng.innerHTML = ' PNG'; btnPng.addEventListener('click', () => downloadMermaidPng(container, btnPng)); const btnCopy = document.createElement('button'); btnCopy.className = 'mermaid-toolbar-btn'; btnCopy.title = 'Copy image to clipboard'; btnCopy.setAttribute('aria-label', 'Copy image to clipboard'); btnCopy.innerHTML = ' Copy'; btnCopy.addEventListener('click', () => copyMermaidImage(container, btnCopy)); const btnSvg = document.createElement('button'); btnSvg.className = 'mermaid-toolbar-btn'; btnSvg.title = 'Download SVG'; btnSvg.setAttribute('aria-label', 'Download SVG'); btnSvg.innerHTML = ' SVG'; btnSvg.addEventListener('click', () => downloadMermaidSvg(container, btnSvg)); toolbar.appendChild(btnZoom); toolbar.appendChild(btnCopy); toolbar.appendChild(btnPng); toolbar.appendChild(btnSvg); container.appendChild(toolbar); }); } // ========================================================================== // Aegis SEO agency Multilingual & Internationalization (i18n) engine // ========================================================================== const I18N_DICTS = { en: { title: "Markdown Viewer", syncOff: "Sync Off", syncOn: "Sync On", import: "Import", importFile: "From files", importGithub: "From GitHub", export: "Export", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Copy", copied: "Copied!", share: "Share", reset: "Reset", editor: "Editor", split: "Split", preview: "Preview", minRead: "Min Read", words: "Words", chars: "Chars", switchRtl: "Switch to RTL", switchLtr: "Switch to LTR", darkMode: "Dark Mode", lightMode: "Light Mode", helpTitle: "Markdown Viewer Help", aboutTitle: "About Markdown", shareTitle: "Share Document", renameTitle: "Rename file", insertLink: "Insert link", insertRef: "Insert reference", insertImg: "Insert image", insertTable: "Insert table", findReplace: "Find & Replace", placeholder: "Type your markdown here...", loadingEmojis: "Loading emojis...", loadingFiles: "Fetching file tree..." }, zh: { title: "Markdown 阅读器", syncOff: "同步关闭", syncOn: "同步开启", import: "导入", importFile: "本地文件导入", importGithub: "从 GitHub 导入", export: "导出", exportMd: "导出 Markdown (.md)", exportHtml: "导出 HTML", exportPdf: "导出 PDF", copy: "复制", copied: "已复制!", share: "分享", reset: "重置", editor: "编辑器", split: "分栏预览", preview: "纯预览", minRead: "分钟阅读", words: "字数", chars: "字符数", switchRtl: "切换为右至左布局", switchLtr: "切换为左至右布局", darkMode: "深色模式", lightMode: "浅色模式", helpTitle: "Markdown 阅读器帮助说明", aboutTitle: "关于 Markdown 阅读器", shareTitle: "分享当前文档", renameTitle: "重命名文件", insertLink: "插入超链接", insertRef: "插入脚注引用", insertImg: "插入图片", insertTable: "插入表格", findReplace: "查找与替换", placeholder: "在此输入您的 Markdown 文本...", loadingEmojis: "正在加载表情...", loadingFiles: "正在获取文件树..." }, ja: { title: "Markdown ビューア", syncOff: "同期オフ", syncOn: "同期オン", import: "インポート", importFile: "ファイルから", importGithub: "GitHub から", export: "エクスポート", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "コピー", copied: "コピー完了!", share: "共有", reset: "リセット", editor: "エディタ", split: "分割表示", preview: "プレビュー", minRead: "分 読了", words: "単語数", chars: "文字数", switchRtl: "RTL表示に切替", switchLtr: "LTR表示に切替", darkMode: "ダークモード", lightMode: "ライトモード", helpTitle: "Markdown ビューア ヘルプ", aboutTitle: "Markdown について", shareTitle: "ドキュメントの共有", renameTitle: "ファイル名の変更", insertLink: "リンクの挿入", insertRef: "参照の挿入", insertImg: "画像の挿入", insertTable: "テーブルの挿入", findReplace: "検索と置換", placeholder: "ここにMarkdownを入力してください...", loadingEmojis: "絵文字を読み込んでいます...", loadingFiles: "ファイルツリーを取得しています..." }, ko: { title: "마크다운 뷰어", syncOff: "동기화 끄기", syncOn: "동기화 켜기", import: "가져오기", importFile: "로컬 파일에서", importGithub: "GitHub에서", export: "내보내기", exportMd: "마크다운 (.md)", exportHtml: "HTML로 내보내기", exportPdf: "PDF로 내보내기", copy: "복사", copied: "복사됨!", share: "공유", reset: "초기화", editor: "편집기", split: "분할 보기", preview: "미리보기", minRead: "분 읽기", words: "단어 수", chars: "글자 수", switchRtl: "우측 정렬로 전환", switchLtr: "좌측 정렬로 전환", darkMode: "다크 모드", lightMode: "라이트 모드", helpTitle: "마크다운 뷰어 도움말", aboutTitle: "마크다운 정보", shareTitle: "문서 공유", renameTitle: "파일 이름 바꾸기", insertLink: "링크 삽입", insertRef: "참조 삽입", insertImg: "이미지 삽입", insertTable: "표 삽입", findReplace: "찾기 및 바꾸기", placeholder: "여기에 마크다운 내용을 입력하세요...", loadingEmojis: "이모지 로딩 중...", loadingFiles: "파일 트리 가져오는 중..." }, pt: { title: "Visualizador de Markdown", syncOff: "Desativar Sincronia", syncOn: "Ativar Sincronia", import: "Importar", importFile: "De arquivos", importGithub: "Do GitHub", export: "Exportar", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Copiar", copied: "Copiado!", share: "Compartilhar", reset: "Redefinir", editor: "Editor", split: "Dividido", preview: "Visualizar", minRead: "Min de leitura", words: "Palavras", chars: "Caracteres", switchRtl: "Mudar para RTL", switchLtr: "Mudar para LTR", darkMode: "Modo Escuro", lightMode: "Modo Claro", helpTitle: "Ajuda do Visualizador de Markdown", aboutTitle: "Sobre o Markdown", shareTitle: "Compartilhar Documento", renameTitle: "Renomear arquivo", insertLink: "Inserir link", insertRef: "Inserir referência", insertImg: "Inserir imagem", insertTable: "Inserir tabela", findReplace: "Localizar & Substituir", placeholder: "Digite seu markdown aqui...", loadingEmojis: "Carregando emojis...", loadingFiles: "Buscando árvore de arquivos..." }, es: { title: "Visualizador de Markdown", syncOff: "Sincronización desactivada", syncOn: "Sincronización activada", import: "Importar", importFile: "Desde archivos", importGithub: "Desde GitHub", export: "Exportar", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Copiar", copied: "¡Copiado!", share: "Compartir", reset: "Restablecer", editor: "Editor", split: "Dividido", preview: "Previsualizar", minRead: "Min de lectura", words: "Palabras", chars: "Caracteres", switchRtl: "Cambiar a RTL", switchLtr: "Cambiar a LTR", darkMode: "Modo oscuro", lightMode: "Modo claro", helpTitle: "Ayuda del Visualizador de Markdown", aboutTitle: "Acerca de Markdown", shareTitle: "Compartir documento", renameTitle: "Renombrar archivo", insertLink: "Insertar enlace", insertRef: "Insertar referencia", insertImg: "Insertar imagen", insertTable: "Insertar tabla", findReplace: "Buscar y reemplazar", placeholder: "Escribe tu markdown aquí...", loadingEmojis: "Cargando emojis...", loadingFiles: "Obteniendo árbol de archivos..." }, fr: { title: "Lecteur Markdown", syncOff: "Désactiver la synchro", syncOn: "Activer la synchro", import: "Importer", importFile: "Depuis des fichiers", importGithub: "Depuis GitHub", export: "Exporter", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Copier", copied: "Copié !", share: "Partager", reset: "Réinitialiser", editor: "Éditeur", split: "Divisé", preview: "Aperçu", minRead: "Min de lecture", words: "Mots", chars: "Caractères", switchRtl: "Passer à RTL", switchLtr: "Passer à LTR", darkMode: "Mode sombre", lightMode: "Mode clair", helpTitle: "Aide du Lecteur Markdown", aboutTitle: "À propos de Markdown", shareTitle: "Partager le document", renameTitle: "Renommer le fichier", insertLink: "Insérer un lien", insertRef: "Insérer une référence", insertImg: "Insérer une image", insertTable: "Insérer un tableau", findReplace: "Rechercher & remplacer", placeholder: "Saisissez votre markdown ici...", loadingEmojis: "Chargement des émojis...", loadingFiles: "Récupération de l'arborescence des fichiers..." }, de: { title: "Markdown Viewer", syncOff: "Synchronisierung aus", syncOn: "Synchronisierung an", import: "Importieren", importFile: "Aus Dateien", importGithub: "Aus GitHub", export: "Exportieren", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Kopieren", copied: "Kopiert!", share: "Teilen", reset: "Zurücksetzen", editor: "Editor", split: "Geteilt", preview: "Vorschau", minRead: "Min. Lesezeit", words: "Wörter", chars: "Zeichen", switchRtl: "Zu RTL wechseln", switchLtr: "Zu LTR wechseln", darkMode: "Dunkelmodus", lightMode: "Heller Modus", helpTitle: "Markdown Viewer Hilfe", aboutTitle: "Über Markdown", shareTitle: "Dokument teilen", renameTitle: "Datei umbenennen", insertLink: "Link einfügen", insertRef: "Referenz einfügen", insertImg: "Bild einfügen", insertTable: "Tabelle einfügen", findReplace: "Suchen & Ersetzen", placeholder: "Geben Sie hier Ihr Markdown ein...", loadingEmojis: "Emojis werden geladen...", loadingFiles: "Dateibaum wird abgerufen..." }, ru: { title: "Просмотрщик Markdown", syncOff: "Синхронизация выкл", syncOn: "Синхронизация вкл", import: "Импорт", importFile: "Из файлов", importGithub: "Из GitHub", export: "Экспорт", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Копировать", copied: "Скопировано!", share: "Поделиться", reset: "Сбросить", editor: "Редактор", split: "Разделение", preview: "Предпросмотр", minRead: "Мин чтения", words: "Слов", chars: "Символов", switchRtl: "Переключить на RTL", switchLtr: "Переключить на LTR", darkMode: "Темная тема", lightMode: "Светлая тема", helpTitle: "Справка Markdown Viewer", aboutTitle: "О Markdown", shareTitle: "Поделиться документом", renameTitle: "Переименовать файл", insertLink: "Вставить ссылку", insertRef: "Вставить сноску", insertImg: "Вставить изображение", insertTable: "Вставить таблицу", findReplace: "Найти и заменить", placeholder: "Введите здесь ваш markdown...", loadingEmojis: "Загрузка эмодзи...", loadingFiles: "Получение списка файлов..." }, it: { title: "Visualizzatore Markdown", syncOff: "Sincronia disattivata", syncOn: "Sincronia attivata", import: "Importa", importFile: "Da file", importGithub: "Da GitHub", export: "Esporta", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Copia", copied: "Copiato!", share: "Condividi", reset: "Ripristina", editor: "Editor", split: "Diviso", preview: "Anteprima", minRead: "Min di lettura", words: "Parole", chars: "Caratteri", switchRtl: "Passa a RTL", switchLtr: "Passa a LTR", darkMode: "Tema scuro", lightMode: "Tema chiaro", helpTitle: "Aiuto di Visualizzatore Markdown", aboutTitle: "Informazioni su Markdown", shareTitle: "Condividi documento", renameTitle: "Rinomina file", insertLink: "Inserisci link", insertRef: "Inserisci riferimento", insertImg: "Inserisci immagine", insertTable: "Inserisci tabella", findReplace: "Trova e sostituisci", placeholder: "Scrivi il tuo markdown qui...", loadingEmojis: "Caricamento emoji...", loadingFiles: "Recupero albero dei file..." }, tr: { title: "Markdown Görüntüleyici", syncOff: "Senkronizasyonu Kapat", syncOn: "Senkronizasyonu Aç", import: "İçe Aktar", importFile: "Dosyalardan", importGithub: "GitHub'dan", export: "Dışa Aktar", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Kopyala", copied: "Kopyalandı!", share: "Paylaş", reset: "Sıfırla", editor: "Editör", split: "Bölünmüş", preview: "Önizleme", minRead: "Dk Okuma", words: "Kelime", chars: "Karakter", switchRtl: "RTL'ye Geç", switchLtr: "LTR'ye Geç", darkMode: "Karanlık Mod", lightMode: "Aydınlık Mod", helpTitle: "Markdown Görüntüleyici Yardımı", aboutTitle: "Markdown Hakkında", shareTitle: "Belgeyi Paylaş", renameTitle: "Dosyayı yeniden adlandır", insertLink: "Bağlantı ekle", insertRef: "Referans ekle", insertImg: "Resim ekle", insertTable: "Tablo ekle", findReplace: "Bul ve Değiştir", placeholder: "Markdown'ınızı buraya yazın...", loadingEmojis: "Emoji'ler yükleniyor...", loadingFiles: "Dosya ağacı alınıyor..." }, pl: { title: "Czytnik Markdown", syncOff: "Wyłącz synchronizację", syncOn: "Włącz synchronizację", import: "Importuj", importFile: "Z plików", importGithub: "Z GitHub", export: "Eksportuj", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Kopiuj", copied: "Skopiowano!", share: "Udostępnij", reset: "Resetuj", editor: "Edytor", split: "Podzielony", preview: "Podgląd", minRead: "Min. czytania", words: "Słowa", chars: "Znaki", switchRtl: "Przełącz na RTL", switchLtr: "Przełącz na LTR", darkMode: "Tryb ciemny", lightMode: "Tryb jasny", helpTitle: "Pomoc Czytnika Markdown", aboutTitle: "O Markdown", shareTitle: "Udostępnij dokument", renameTitle: "Zmień nazwę pliku", insertLink: "Wstaw link", insertRef: "Wstaw odnośnik", insertImg: "Wstaw obraz", insertTable: "Wstaw tabelę", findReplace: "Znajdź i zamień", placeholder: "Wpisz tutaj swój markdown...", loadingEmojis: "Ładowanie emoji...", loadingFiles: "Pobieranie drzewa plików..." }, tw: { title: "Markdown 閱讀器", syncOff: "同步關閉", syncOn: "同步開啟", import: "匯入", importFile: "本地檔案匯入", importGithub: "從 GitHub 匯入", export: "匯出", exportMd: "匯出 Markdown (.md)", exportHtml: "匯出 HTML", exportPdf: "匯出 PDF", copy: "複製", copied: "已複製!", share: "分享", reset: "重置", editor: "編輯器", split: "分欄預覽", preview: "純預覽", minRead: "分鐘閱讀", words: "字數", chars: "字元數", switchRtl: "切換為右至左佈局", switchLtr: "切換為左至右佈局", darkMode: "深色模式", lightMode: "淺色模式", helpTitle: "Markdown 閱讀器說明", aboutTitle: "關於 Markdown 閱讀器", shareTitle: "分享當前文件", renameTitle: "重新命名檔案", insertLink: "插入超連結", insertRef: "插入腳註引用", insertImg: "插入圖片", insertTable: "插入表格", findReplace: "尋找與取代", placeholder: "在此輸入您的 Markdown 文本...", loadingEmojis: "正在載入表情...", loadingFiles: "正在獲取檔案樹..." }, uk: { title: "Переглядач Markdown", syncOff: "Вимкнути синхронізацію", syncOn: "Увімкнути синхронізацію", import: "Імпорт", importFile: "З файлів", importGithub: "З GitHub", export: "Експорт", exportMd: "Markdown (.md)", exportHtml: "HTML", exportPdf: "PDF", copy: "Копіювати", copied: "Скопійовано!", share: "Поділитися", reset: "Скинути", editor: "Редактор", split: "Розділений", preview: "Перегляд", minRead: "Хв читання", words: "Слів", chars: "Символів", switchRtl: "Перемкнути на RTL", switchLtr: "Перемкнути на LTR", darkMode: "Темний режим", lightMode: "Світлий режим", helpTitle: "Довідка Markdown Viewer", aboutTitle: "Про Markdown", shareTitle: "Поділитися документом", renameTitle: "Перейменувати файл", insertLink: "Вставити посилання", insertRef: "Вставити витяг", insertImg: "Вставити зображення", insertTable: "Вставити таблицю", findReplace: "Знайти та замінити", placeholder: "Введіть ваш markdown тут...", loadingEmojis: "Завантаження емодзі...", loadingFiles: "Отримання структури файлів..." } }; let activeLang = 'en'; function applyTranslations(lang) { activeLang = lang; document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh-Hans' : (lang === 'tw' ? 'zh-Hant' : lang)); const dict = I18N_DICTS[lang] || I18N_DICTS.en; // Update main logo and header elements const logoEl = document.querySelector('.app-header h1'); if (logoEl) logoEl.textContent = dict.title; // Update dynamic current language labels in drop menus const labelEl = document.getElementById('current-lang-label'); if (labelEl) { const flags = { en: "🇺🇸 English", zh: "🇨🇳 简体中文", ja: "🇯🇵 日本語", ko: "🇰🇷 한국어", pt: "🇧🇷 Português (Brasil)", es: "🇪🇸 Español", fr: "🇫🇷 Français", de: "🇩🇪 Deutsch", ru: "🇷🇺 Русский", it: "🇮🇹 Italiano", tr: "🇹🇷 Türkçe", pl: "🇵🇱 Polski", tw: "🇹🇼 繁體中文", uk: "🇺🇦 Українська" }; labelEl.textContent = flags[lang]; } const mobileLabelEl = document.getElementById('mobile-current-lang-label'); if (mobileLabelEl) { const flags = { en: "us English", zh: "CN 简体中文", ja: "JP 日本語", ko: "KR 한국어", pt: "BR Português (Brasil)", es: "ES Español", fr: "FR Français", de: "DE Deutsch", ru: "RU Русский", it: "IT Italiano", tr: "TR Türkçe", pl: "PL Polski", tw: "TW 繁體中文", uk: "UK Українська" }; mobileLabelEl.textContent = flags[lang]; } // Translate buttons with text content const toggleSyncEl = document.getElementById('toggle-sync'); if (toggleSyncEl) { const isSyncActive = toggleSyncEl.classList.contains('sync-active'); const textSpan = toggleSyncEl.querySelector('.btn-text'); if (textSpan) textSpan.textContent = isSyncActive ? dict.syncOff : dict.syncOn; } const mobileToggleSyncEl = document.getElementById('mobile-toggle-sync'); if (mobileToggleSyncEl) { const isSyncActive = mobileToggleSyncEl.classList.contains('sync-active'); mobileToggleSyncEl.innerHTML = ` ${isSyncActive ? dict.syncOff : dict.syncOn}`; } // Import buttons const importDropEl = document.getElementById('importDropdown'); if (importDropEl) { const importText = importDropEl.querySelector('.btn-text'); if (importText) importText.textContent = dict.import; } const importFileEl = document.getElementById('import-from-file'); if (importFileEl) importFileEl.innerHTML = `${dict.importFile}`; const importGithubEl = document.getElementById('import-from-github'); if (importGithubEl) importGithubEl.innerHTML = `${dict.importGithub}`; const mImportFileEl = document.getElementById('mobile-import-button'); if (mImportFileEl) mImportFileEl.innerHTML = `${dict.importFile}`; const mImportGithubEl = document.getElementById('mobile-import-github-button'); if (mImportGithubEl) mImportGithubEl.innerHTML = `${dict.importGithub}`; // Export buttons const exportDropEl = document.getElementById('exportDropdown'); if (exportDropEl) { const exportText = exportDropEl.querySelector('.btn-text'); if (exportText) exportText.textContent = dict.export; } const exportMdEl = document.getElementById('export-md'); if (exportMdEl) exportMdEl.innerHTML = `${dict.exportMd}`; const exportHtmlEl = document.getElementById('export-html'); if (exportHtmlEl) exportHtmlEl.innerHTML = `${dict.exportHtml}`; const exportPdfEl = document.getElementById('export-pdf'); if (exportPdfEl) exportPdfEl.innerHTML = `${dict.exportPdf}`; const mExportMdEl = document.getElementById('mobile-export-md'); if (mExportMdEl) mExportMdEl.innerHTML = `${dict.exportMd}`; const mExportHtmlEl = document.getElementById('mobile-export-html'); if (mExportHtmlEl) mExportHtmlEl.innerHTML = `${dict.exportHtml}`; const mExportPdfEl = document.getElementById('mobile-export-pdf'); if (mExportPdfEl) mExportPdfEl.innerHTML = `${dict.exportPdf}`; // Copy / Share if (copyMarkdownButton) { const copyButtonText = copyMarkdownButton.querySelector('.btn-text'); if (copyButtonText) copyButtonText.textContent = dict.copy; } const mCopyBtn = document.getElementById('mobile-copy-markdown'); if (mCopyBtn) mCopyBtn.innerHTML = `${dict.copy}`; if (shareButton) { const shareButtonText = shareButton.querySelector('.btn-text'); if (shareButtonText) shareButtonText.textContent = dict.share; } const mShareBtn = document.getElementById('mobile-share-button'); if (mShareBtn) mShareBtn.innerHTML = `${dict.share}`; // Document Reset const tabResetBtn = document.getElementById('tab-reset-btn'); if (tabResetBtn) tabResetBtn.innerHTML = ` ${dict.reset}`; const mTabResetBtn = document.getElementById('mobile-tab-reset-btn'); if (mTabResetBtn) mTabResetBtn.innerHTML = ` ${dict.reset} all files`; // View toggle buttons title tooltips document.querySelectorAll('[data-view-mode="editor"]').forEach(b => b.title = dict.editor); document.querySelectorAll('[data-view-mode="split"]').forEach(b => b.title = dict.split); document.querySelectorAll('[data-view-mode="preview"]').forEach(b => b.title = dict.preview); document.querySelectorAll('.mobile-view-mode-btn[data-mode="editor"] span').forEach(s => s.textContent = dict.editor); document.querySelectorAll('.mobile-view-mode-btn[data-mode="split"] span').forEach(s => s.textContent = dict.split); document.querySelectorAll('.mobile-view-mode-btn[data-mode="preview"] span').forEach(s => s.textContent = dict.preview); // Direction Toggle const dirToggle = document.getElementById('direction-toggle'); if (dirToggle) { const isRtl = document.body.style.direction === 'rtl'; dirToggle.title = isRtl ? dict.switchLtr : dict.switchRtl; } // Modal Titles const modalHelpTitle = document.getElementById('help-modal-title'); if (modalHelpTitle) modalHelpTitle.textContent = dict.helpTitle; const modalAboutTitle = document.getElementById('about-modal-title'); if (modalAboutTitle) modalAboutTitle.textContent = dict.aboutTitle; const modalShareTitle = document.getElementById('share-modal-title'); if (modalShareTitle) modalShareTitle.textContent = dict.shareTitle; const modalRenameTitle = document.getElementById('rename-modal-title'); if (modalRenameTitle) modalRenameTitle.textContent = dict.renameTitle; const modalLinkTitle = document.getElementById('link-modal-title'); if (modalLinkTitle) modalLinkTitle.textContent = dict.insertLink; const modalRefTitle = document.getElementById('reference-modal-title'); if (modalRefTitle) modalRefTitle.textContent = dict.insertRef; const modalImgTitle = document.getElementById('image-modal-title'); if (modalImgTitle) modalImgTitle.textContent = dict.insertImg; const modalTableTitle = document.getElementById('table-modal-title'); if (modalTableTitle) modalTableTitle.textContent = dict.insertTable; const modalFindTitle = document.getElementById('find-replace-title'); if (modalFindTitle) modalFindTitle.textContent = dict.findReplace; // Theme titles const mThemeToggle = document.getElementById('mobile-theme-toggle'); if (mThemeToggle) { const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; mThemeToggle.innerHTML = ` ${currentTheme === 'dark' ? dict.lightMode : dict.darkMode}`; } // Stats Labels const minReadEl = document.getElementById('lbl-min-read'); if (minReadEl) minReadEl.textContent = dict.minRead; const wordsEl = document.getElementById('lbl-words'); if (wordsEl) wordsEl.textContent = dict.words; const charsEl = document.getElementById('lbl-chars'); if (charsEl) charsEl.textContent = dict.chars; const mMinReadEl = document.getElementById('lbl-mobile-min-read'); if (mMinReadEl) mMinReadEl.textContent = dict.minRead; const mWordsEl = document.getElementById('lbl-mobile-words'); if (mWordsEl) mWordsEl.textContent = dict.words; const mCharsEl = document.getElementById('lbl-mobile-chars'); if (mCharsEl) mCharsEl.textContent = dict.chars; // Placeholder if (markdownEditor) { markdownEditor.placeholder = dict.placeholder; } // Trigger state tracking update updateDocumentStats(); // Mark current selected dropdown items as active document.querySelectorAll('.lang-select-item').forEach(item => { if (item.getAttribute('data-lang') === lang) { item.classList.add('active'); } else { item.classList.remove('active'); } }); } function detectAndInitLanguage() { const urlParams = new URLSearchParams(window.location.search); let lang = urlParams.get('lang'); if (!lang) { const hash = window.location.hash; const hashParams = new URLSearchParams(hash.includes('?') ? hash.split('?')[1] : ''); lang = hashParams.get('lang'); } if (!lang) { lang = localStorage.getItem('app-lang'); } if (!lang && navigator.language) { const navLang = navigator.language.toLowerCase(); if (navLang.startsWith('zh-tw') || navLang.startsWith('zh-hk') || navLang.startsWith('zh-hant')) lang = 'tw'; else if (navLang.startsWith('zh')) lang = 'zh'; else if (navLang.startsWith('ja')) lang = 'ja'; else if (navLang.startsWith('ko')) lang = 'ko'; else if (navLang.startsWith('pt')) lang = 'pt'; else if (navLang.startsWith('es')) lang = 'es'; else if (navLang.startsWith('fr')) lang = 'fr'; else if (navLang.startsWith('de')) lang = 'de'; else if (navLang.startsWith('ru')) lang = 'ru'; else if (navLang.startsWith('it')) lang = 'it'; else if (navLang.startsWith('tr')) lang = 'tr'; else if (navLang.startsWith('pl')) lang = 'pl'; else if (navLang.startsWith('uk')) lang = 'uk'; } if (!lang || !I18N_DICTS[lang]) { lang = 'en'; } applyTranslations(lang); } // Language selectors click event listeners document.addEventListener('click', function(e) { const item = e.target.closest('.lang-select-item'); if (item) { e.preventDefault(); const lang = item.getAttribute('data-lang'); applyTranslations(lang); localStorage.setItem('app-lang', lang); // Update browser search parameters dynamically without page reload const url = new URL(window.location.href); url.searchParams.set('lang', lang); window.history.replaceState({}, '', url.toString()); } }); // Accessibility dynamic screen reader announcer helper function announceToScreenReader(message) { const announcer = document.getElementById('app-accessibility-announcer'); if (!announcer) return; announcer.textContent = ''; clearTimeout(announceToScreenReader._timeoutId); announceToScreenReader._timeoutId = setTimeout(() => { announcer.textContent = message; }, 50); } // Visual skeleton loader generator for emoji list function renderEmojiSkeletons() { const grid = document.getElementById('emoji-modal-grid'); if (!grid) return; // PERF-007: Clear elements using textContent grid.textContent = ''; const fragment = document.createDocumentFragment(); for (let i = 0; i < 18; i++) { const item = document.createElement('div'); item.className = 'emoji-item skeleton-placeholder'; item.setAttribute('aria-hidden', 'true'); item.style.border = '1px solid var(--border-color)'; item.style.borderRadius = '10px'; item.style.padding = '10px'; item.style.display = 'flex'; item.style.flexDirection = 'column'; item.style.alignItems = 'center'; item.style.gap = '8px'; const preview = document.createElement('span'); preview.className = 'emoji-preview skeleton-circle'; item.appendChild(preview); const shortcodeRow = document.createElement('div'); shortcodeRow.className = 'emoji-shortcode'; const code = document.createElement('span'); code.className = 'skeleton-text'; code.style.width = '60px'; shortcodeRow.appendChild(code); item.appendChild(shortcodeRow); fragment.appendChild(item); } grid.appendChild(fragment); } // Visual skeleton loader generator for GitHub file list tree function renderGitHubImportTreeSkeleton() { if (!githubImportTree) return; // PERF-007: Clear elements using textContent githubImportTree.textContent = ''; const wrapper = document.createElement('div'); wrapper.className = 'github-import-tree-skeleton'; const list = document.createElement('ul'); list.style.listStyle = 'none'; list.style.paddingLeft = '4px'; list.style.margin = '0'; for (let i = 0; i < 4; i++) { const folderItem = document.createElement('li'); folderItem.style.margin = '6px 0'; const folderSpan = document.createElement('span'); folderSpan.className = 'skeleton-placeholder skeleton-tree-folder'; folderItem.appendChild(folderSpan); const subList = document.createElement('ul'); subList.style.listStyle = 'none'; subList.style.paddingLeft = '18px'; subList.style.margin = '0'; for (let j = 0; j < 2; j++) { const fileItem = document.createElement('li'); fileItem.style.margin = '4px 0'; const fileSpan = document.createElement('span'); fileSpan.className = 'skeleton-placeholder skeleton-tree-file'; fileItem.appendChild(fileSpan); subList.appendChild(fileItem); } folderItem.appendChild(subList); list.appendChild(folderItem); } wrapper.appendChild(list); githubImportTree.appendChild(wrapper); } // Run detection detectAndInitLanguage(); // Intercept all link clicks in the preview pane to open them securely and prevent page navigation if (markdownPreview) { markdownPreview.addEventListener('click', function(e) { const link = e.target.closest('a'); if (link) { const href = link.getAttribute('href'); if (href) { if (href.startsWith('#')) { const targetId = decodeURIComponent(href.slice(1)); let targetEl = null; if (targetId) { try { targetEl = markdownPreview.querySelector(`[id="${CSS.escape(targetId)}"]`) || markdownPreview.querySelector(`[name="${CSS.escape(targetId)}"]`); } catch (err) { targetEl = Array.from(markdownPreview.querySelectorAll('[id], [name]')).find(el => { return el.getAttribute('id') === targetId || el.getAttribute('name') === targetId; }); } if (!targetEl) { const cleanTargetId = targetId.toLowerCase().replace(/[^a-z0-9]/g, ''); if (cleanTargetId) { targetEl = Array.from(markdownPreview.querySelectorAll('h1, h2, h3, h4, h5, h6')).find(heading => { const cleanText = heading.textContent.toLowerCase().replace(/[^a-z0-9]/g, ''); return cleanText === cleanTargetId; }); } } } if (targetEl) { e.preventDefault(); isProgrammaticScrolling = true; // Scroll preview pane to target heading targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Scroll editor pane to the matching synced position const previewScrollRange = previewPane.scrollHeight - previewPane.clientHeight; const targetRatio = previewScrollRange > 0 ? Math.min(1, Math.max(0, targetEl.offsetTop / previewScrollRange)) : 0; const editorScrollPosition = targetRatio * (markdownEditor.scrollHeight - markdownEditor.clientHeight); markdownEditor.scrollTo({ top: editorScrollPosition, behavior: 'smooth' }); if (window.programmaticScrollTimeout) { clearTimeout(window.programmaticScrollTimeout); } window.programmaticScrollTimeout = setTimeout(() => { isProgrammaticScrolling = false; }, 1000); } return; } e.preventDefault(); // Defense-in-depth: check that the URL protocol is safe let isSafe = false; try { const parsed = new URL(href, window.location.href); isSafe = ['http:', 'https:', 'mailto:', 'tel:', 'blob:'].includes(parsed.protocol); } catch (err) { // If URL constructor fails, it might be a relative path without a base, which is safe to resolve isSafe = !href.trim().toLowerCase().startsWith('javascript:'); } if (isSafe) { if (typeof Neutralino !== 'undefined') { Neutralino.os.open(href); } else { window.open(href, '_blank', 'noopener,noreferrer'); } } else { console.warn('Blocked opening potentially unsafe URL:', href); } } } }); } // Register Service Worker for offline capabilities if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('sw.js').then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }, function(err) { console.log('ServiceWorker registration failed: ', err); }); }); } });