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(
``
);
});
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 ``;
});
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`;
}
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 ``;
}
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 ``;
}
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++\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 '';
}
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 = `
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://markdownplusplus.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++",
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++ 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++",
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++ 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++",
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++",
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);
});
});
}
});